_little-star_

学习的博客

0%

[TOC]

1、Redis初级(Windows)

1、Redis入门

1、Redis 简介

1、Nosql的出现

NoSql出现的解决的问题:

  • 海量用户
  • 高并发

罪魁祸首——关系型数据库:

  • 性能瓶颈:磁盘IO性能低下
  • 扩展瓶颈:数据关系复杂,扩展性差,不便于大规模集群

解决思路:

  • 降低磁盘IO次数,越低越好—— 内存存储
  • 去除数据间关系,越简单越好——不存储关系,仅存储数据

以上解决思路的实际实现:NoSql

2、Nosql 简介

NoSQL:即 Not-Only SQL( 泛指非关系型的数据库),作为关系型数据库的补充。

作用:应对基于海量用户和海量数据前提下的数据处理问题

特征:

  • 可扩容,可伸缩
  • 大数据量下高性能
  • 灵活的数据模型
  • 高可用

常见 Nosql 数据库:

  • ==Redis==
  • memcache
  • HBase
  • MongoDB

3、具体解决方案 ——(电商场景)

  1. 商品基本信息 ——MySQL
    • 名称
    • 价格
    • 厂商
  2. 商品附加信息 —— MongoDB
    • 描述
    • 详情
    • 评论
  3. 图片信息 —— 分布式文件系统
  4. 搜索关键字 —— ES、Lucene、solr
  5. 热点信息 —— Redis、memcache、tair
    • 高频
    • 波段性

image-20210904222259291

4、Redis

概念:Redis (REmote DIctionary Server) 是用 ==C 语言==开发的一个==开源==的高性能==键值对==(key-value)数据库。

在线测试:http://try.redis.io/

使用文档:http://doc.redisfans.com/

特征:

  1. 数据间没有必然的关联关系
  2. 内部采用单线程机制进行工作
  3. 高性能。官方提供测试数据,50个并发执行100000 个请求,读的速度是110000 次/s,写的速度是81000次/s。
  4. 多数据类型支持
    • 字符串类型——string
    • 列表类型——list
    • 散列类型——hash
    • 集合类型——set
    • 有序集合类型——sorted_set
  5. 这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。
  6. 在此基础上,Redis支持各种不同方式的排序
  7. 与memcached一样,为了保证效率,数据都是缓存在内存中。
  8. 区别的是Redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件。
  9. 持久化支持。可以进行数据灾难恢复
  10. 并且在此基础上实现了**master-slave(主从)**同步

Redis是单线程+多路IO复用技术

多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)

==串行 vs 多线程+锁(memcached) vs 单线程+多路IO复用(Redis)==

(与Memcache三点不同: 支持多数据类型,支持持久化,单线程+多路IO复用)

1

5、Redis 的应用

  • 配合关系型数据库做高速缓存
    • 为热点数据加速查询(主要场景),如热点商品、热点新闻、热点资讯、推广类等高访问量信息等
    • 任务队列,如秒杀、抢购、购票排队等
    • 即时信息查询,如各位排行榜、各类网站访问统计、公交到站信息、在线人数信息(聊天室、网站)、设备信号等
    • 时效性信息控制,如验证码控制、投票控制等
    • 布式数据共享,如分布式集群架构中的 session 分离
  • 多样的数据结构存储持久化数据
  • 消息队列
  • 分布式锁

2、Redis 的下载与安装

1、Redis 的下载

Linux 版:(适用于企业级开发)

  • Redis 高级开始使用
  • 以4.0 版本作为主版本

Windows 版本(适合零基础学习)

  • Redis 入门使用
  • 以 3.2 版本作为主版本
  • 下载地址

2、安装 Redis

image-20210904224324322

核心文件:

  • redis-server.exe:服务器启动命令
  • redis-cli.exe:命令行客户端
  • redis.windows.conf:redis核心配置文件
    • Linux环境下是redis.conf
  • redis-benchmark.exe :性能测试工具,可以在自己本子运行,看看自己本子性能如何
  • redis-check-aof.exe:AOF文件修复工具,修复有问题的AOF文件
  • redis-check-dump.exe:RDB文件检查工具(快照持久化文件),修复有问题的dump.rdb文件
  • 在Linux环境下还有一个redis-sentinel:Redis集群使用

3、启动 Redis

服务器启动:

1、前台启动(不推荐)
  • 端口:6379
  • PID:随机生成

image-20210904224540396

客户端连接:image-20210904224628786

前台启动,命令行窗口不能关闭,否则服务器停止。

2、后台启动(推荐)

修改redis.windows.conf文件将里面的daemonize no 改成 yes,让服务在后台启动。

可以使用客户端Ping一下看看能不能连接成功

3、Redis 的基本操作

1、命令行模式工具使用思考

  • 功能性命令
  • 清除屏幕信息
  • 帮助信息查阅
  • 退出指令

2、信息添加

  • 功能:设置 key,value 数据

  • 命令

    1
    set key value
  • 范例

    1
    set name zhangsan

3、信息查询

  • 功能:根据 key 查询对应的 value,如果不存在,返回空(nil)

  • 命令

    1
    get key
  • 范例

    1
    get name

4、清除屏幕信息

  • 功能:清除屏幕中的信息

  • 命令

    1
    clear

5、退出客户端命令行模式

  • 功能:退出客户端

  • 命令

    1
    2
    3
    quit
    exit
    <ESC>

6、帮助

  • 功能:获取命令帮助文档,获取组中所有命令信息名称

  • 命令

    1
    2
    help 命令名称
    help @组名

    image-20210904225148243

    image-20210904225203708


2、Redis 数据类型

1、数据存储类型介绍

1、业务数据的特殊性

1、作为缓存使用
  1. 原始业务功能设计
    • 秒杀
    • 618活动
    • 双11活动
    • 排队购票
  2. 运营平台监控到的突发高频访问数据
    • 突发时政要闻,被强势关注围观
  3. 高频、复杂的统计数据
  • 在线人数
  • 投票排行榜
2、附加功能

系统功能优化或升级

  • 单服务器升级集群
  • Session 管理
  • Token 管理

2、Redis 数据类型(5种常用)

redis java
string String
hash HashMap
list LinkedList
set HashSet
sorted_set TreeSet

2、String

1、redis 数据存储格式

redis 自身是一个 Map,其中所有的数据都是采用 key : value 的形式存储

数据类型指的是存储的数据的类型,也就是 value 部分的类型,==key 部分永远都是字符串==

image-20210905002926897

2、string 类型

  • 存储的数据:单个数据,最简单的数据存储类型,也是最常用的数据存储类型
  • 存储数据的格式:一个存储空间保存一个数据
  • 存储内容:通常使用字符串,如果字符串以整数的形式展示,可以作为数字操作使用
    • String类型是二进制安全的。
      • 意味着Redis的string可以包含任何数据。比如jpg图片或者序列化的对象。
    • String类型是Redis最基本的数据类型,一个Redis中字符串value最多可以是512M

image-20210905003004908

3、String类型的数据结构

String的数据结构为简单动态字符串(Simple Dynamic String,缩写SDS)。是可以修改的字符串,内部结构实现上类似于Java的ArrayList,采用==预分配冗余空间==的方式来减少内存的频繁分配.

image-20210907223152303

如图中所示:

  • 内部为当前字符串实际分配的空间capacity一般要高于实际字符串长度len
  • 当字符串长度小于1M时,扩容都是加倍现有的空间,如果超过1M,扩容时一次只会多扩1M的空间
  • 需要注意的是字符串最大长度为512M

4、string 类型数据的基本操作

  • 添加/修改数据

    1
    set key value
  • 只有在 key 不存在时 设置 key 的值

    1
    setnx key value
  • 用 value 覆写 key 所储存的字符串值,从<起始位置>开始(索引从0开始)。

    1
    setrange <key><起始位置><value>
  • 获取数据

    1
    get key
  • 获得值的范围,类似java中的substring,前包,后包

    1
    getrange  <key><起始位置><结束位置>
  • 删除数据

    1
    del key
  • 添加/修改多个数据(m:Multiple[ˈmʌltɪpl])

    1
    mset key1 value1 key2 value2 …
  • 获取多个数据

    1
    mget key1 key2 …
  • 获取数据字符个数(字符串长度)

    1
    strlen key
  • 追加信息到原始信息后部(如果原始信息存在就追加,否则新建)

    1
    append key value
  • 设置数值数据增加指定范围的值(操作具有原子性)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 将指定的key的值加1
    incr key

    # 将指定的key的值加 increment (increment为整型)
    # 当然increment也可以为负数,若increment为负数,则功能相当于decrby
    incrby key increment

    # 将指定的key的值加 increment,increment为浮点数
    incrbyfloat key increment
  • 设置数值数据减少指定范围的值

    1
    2
    3
    4
    5
    6
    # 将指定的key的值减1
    decr key

    # 将指定的key的值减 increment (increment为整型)
    # 当然increment也可以为负数,若increment为负数,则功能相当于incrby
    decrby key increment
  • 设置数据具有指定的生命周期

    1
    2
    3
    setex key seconds value

    psetex key milliseconds value

单数据操作与多数据操作的选择之惑:

1
2
3
set key value

mset key1 value1 key2 value2 …

image-20210905003405704

  • 假设需要花费时间的操作有:
    • set 过程
    • 存储过程
    • 返回结果的过程(result)
  • 并且set与result的时间一样
  • 发送100条数据
    • 单指令发送:200 * set/result过程 + 100 * 存储过程
    • 多指令发送:2 * set/result过程 + 100 * 存储过程
  • 这样看来似乎多数据操作会比单数据操作好
  • 其实不然,看似多数据操作会比单数据操作好要快,但是多数据操作数据的回馈并没有比单数据操作好
    • 这里的数据的回馈指的是进行展示的数据
  • 当数据量达到一亿,一次性发送一亿的数据,客户端这边需要等待数据存储的过程将会更长,而使用100万次发送100万次数据的复合操作来说,用户的体验会更好
  • 结论:具体情况具体分析。

5、string 作为数值操作

  • string在redis内部存储默认就是一个字符串,当遇到增减类操作incr,decr时会转成数值型进行计算
  • redis所有的操作都是原子性的,采用单线程处理所有业务,命令是一个一个执行的,因此无需考虑并发带来的数据影响。
  • 注意:按数值进行操作的数据,如果原始数据不能转成数值,或超越了redis 数值上限范围,将报错。
    9223372036854775807(java中long型数据最大值,Long.MAX_VALUE)

6、string 类型数据操作的注意事项

  • 数据操作不成功的反馈与数据正常操作之间的差异
    • 表示运行结果是否成功
      • (integer) 0 → false:失败
      • (integer) 1 → true:成功
    • 表示运行结果值
      • (integer) 3 → 3:3个
      • (integer) 1 → 1:1个
  • 数据未获取到
    • (nil)等同于null
  • 数据最大存储量
    • 512MB
  • 数值计算最大范围(java中的long的最大值)
    • 9223372036854775807

7、string 类型应用场景

1、业务场景

主页高频访问信息显示控制,例如新浪微博大V主页显示粉丝数与微博数量

image-20210905005605687

2、解决方案
  • 在redis中为大V用户设定用户信息,以用户主键和属性值作为key,后台设定定时刷新策略即可

    1
    2
    3
    4
    5
    user:id:3506728370:fans → 12210947

    user:id:3506728370:blogs → 6164

    user:id:3506728370:focuss → 83
  • 在redis中以json格式存储大V用户信息,定时刷新(也可以使用hash类型)

    1
    user:id:3506728370 → {"id":3506728370,"name":"春晚","fans":12210862,"blogs":6164, "focus":83}

8、key 的设置约定

数据库中的热点数据key命名惯例

image-20210905010101165

9、string 类型应用场景

  • Tips 1

    • redis用于控制数据库表主键id,为数据库表主键提供生成策略,保障数据库表的主键唯一性

    • 此方案适用于所有数据库,且支持数据库集群

      • 大型企业级应用中,分表操作是基本操作,使用多张表存储同类型数据,但是对应的主键 id 必须保证统一性,不能重复。Oracle 数据库具有 sequence 设定,可以解决该问题,redis 可以解决 MySQL数据库该问题

        image-20210905004145945

  • Tips 2

    • redis 控制数据的生命周期,通过数据是否失效控制业务行为,适用于所有具有时效性限定控制的操作
      • “最强女生”启动海选投票,只能通过微信投票,每个微信号每 4 小时只能投1票。
      • 电商商家开启热门商品推荐,热门商品不能一直处于热门期,每种商品热门期维持3天,3天后自动取消热门。
      • 新闻网站会出现热点新闻,热点新闻最大的特征是时效性,如何自动控制热点新闻的时效性。
  • Tips 3

    • redis应用于各种结构型和非结构型高热度数据访问加速
      • 主页高频访问信息显示控制,例如新浪微博大V主页显示粉丝数与微博数量

3、hash

1、hash 类型

存储的困惑

对象类数据的存储如果具有较频繁的更新需求操作会显得笨重

image-20210905010321037

  • 新的存储需求:对一系列存储的数据进行编组,方便管理,典型应用存储对象信息
  • 需要的存储结构:一个存储空间保存多个键值对数据
  • hash类型:底层使用哈希表结构实现数据存储

image-20210905010413422

hash存储结构优化:

  • 如果field数量较少,存储结构优化为类数组结构
  • 如果field数量较多,存储结构使用HashMap结构

2、Hash 的数据结构

Hash类型对应的数据结构是两种:

  • ziplist(压缩列表)
  • hashtable(哈希表)。

当field-value长度较短且个数较少时,使用ziplist,否则使用hashtable。

3、hash 类型数据的基本操作

  • 添加/修改数据

    1
    2
    # 设置存储的hashMap 的key 和 value
    hset key field value
  • 获取数据

    1
    2
    3
    4
    5
    # 获取存储的 hashMap的key——field
    hget key field

    # 获取存储的hashMap的所有key
    hgetall key
  • 删除数据

    1
    hdel key field1 [field2]
  • 添加/修改多个数据

    1
    hmset key field1 value1 field2 value2 …
  • 获取多个数据

    1
    hmget key field1 field2 …
  • 获取哈希表中字段的数量

    1
    hlen key
  • 获取哈希表中是否存在指定的字段

    1
    hexists key field
  • 获取哈希表中所有的字段名或字段值

    1
    2
    3
    hkeys key

    hvals key
  • 设置指定字段的数值数据增加指定范围的值

    1
    2
    3
    4
    5
    # 整型
    hincrby key field increment

    # 浮点
    hincrbyfloat key field increment
  • 如果key存在就不改变,如果key不存在就设置filed 与 value

    1
    hsetnx key field value

4、hash 类型数据操作的注意事项

  • hash类型下的value只能存储==字符串==**,不允许存储其他数据类型,不存在嵌套现象如果数据未获取到,对应的值为(nil)**
  • 每个 hash 可以存储 2^32 - 1 个键值对
  • hash类型十分贴近对象的数据存储形式,并且可以灵活添加删除对象属性。但hash设计初衷不是为了存储大量对象而设计的,切记不可滥用,更不可以将hash作为对象列表使用
  • hgetall 操作可以获取全部属性,如果内部field过多,遍历整体数据效率就很会低,有可能成为数据访问瓶颈

5、string存储对象(json)与hash存储对象

  • string存储对象(json)
    • 讲究整体性——一次性数据以整体操作:要么一次性更新,要么一次性获取
    • 讲究的是以 ==读== 为主
  • hash存储对象
    • 由于使用hash存储的话可以使用field将属性隔离开,所以hash讲究的是==更新==操作
    • hash讲究的是==群组==概念,把一系列的数据包装成一个群组,对外产生唯一一个接口——key
    • 如果业务环境以更新操作或修改数量比较多的操作,推荐使用hash的方法存储对象
  • 总结:具体情况具体分析

6、hash 类型应用场景

  • Tips 4
    • redis 应用于购物车数据存储设计
      • 电商网站购物车设计与实现
  • Tips 5
    • redis 应用于抢购,限购类、限量发放优惠卷、激活码等业务的数据存储设计
      • 双11活动日,销售手机充值卡的商家对移动、联通、电信的30元、50元、100元商品推出抢购活动,每种商品抢购上限1000张

4、list

1、list 类型

  • 数据存储需求:存储多个数据,并对数据进入存储空间的顺序进行区分
  • 需要的存储结构:一个存储空间保存多个数据,且通过数据可以体现进入顺序
  • list类型:保存多个数据,底层使用双向链表存储结构实现

image-20210905013542306

image-20210905013558179

2、list 的数据结构

List的数据结构为快速链表quickList

首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是ziplist,也即是压缩列表

  • 它将所有的元素紧挨着一起存储,分配的是一块连续的内存。

当数据量比较多的时候才会改成quicklist

因为普通的链表需要的附加指针空间太大,会比较浪费空间。比如这个列表里存的只是int类型的数据,结构上还需要两个额外的指针prev和next。

image-20210907224104786

Redis将链表和ziplist结合起来组成了quicklist。也就是将多个ziplist使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。

3、list 类型数据基本操作

  • 添加/修改数据

    1
    2
    3
    4
    5
    # 从队列左边添加数据
    lpush key value1 [value2] ……

    # 从队列右边添加数据
    rpush key value1 [value2] ……
  • 获取数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 从队列的左边获取数据,从start到stop(队列的最右边第一个数据的下标为-1)
    # 所以取出所有数据的命令为:lrange key 0 -1
    lrange key start stop

    # 从队列的左边获取第index个数据
    lindex key index

    # 队列的key个数
    llen key
  • 获取并移除数据(值在键在,值光键亡

    1
    2
    lpop key
    rpop key
  • 规定时间内获取并移除数据

    1
    2
    3
    4
    5
    6
    7
    8
    # 在规定时间内从左/右边获取并移除数据,若以达规定时间key1没有数据,返回(nil)
    blpop key1 [key2] timeout
    brpop key1 [key2] timeout

    # 从列表中取出最后一个元素,并插入到另外一个列表的头部;
    # 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
    # source 为 想要取出数据的列表,destination 为 目标列表,timeout 为超时时间
    brpoplpush source destination timeout
  • 从key1列表右边吐出一个值,插到key2列表左边。

    1
    rpoplpush  <key1><key2>
  • 在value的后面插入newvalue插入值

    1
    linsert <key>  before <value><newvalue>
  • 将列表key下标为index的值替换成value

    1
    lset <key><index><value>
  • 移除指定数据

    1
    lrem key count value

4、list 类型数据操作注意事项

  • list中保存的数据都是string类型的,数据总容量是有限的,最多2^32 - 1 个元素 (4294967295)。
  • list具有索引的概念,但是操作数据时通常以队列的形式进行入队出队操作,或以栈的形式进行入栈出栈操作
  • 获取全部数据操作结束索引设置为-1
  • list可以对数据进行分页操作,通常第一页的信息来自于list,第2页及更多的信息通过数据库的形式加载

5、list 类型应用场景

  • **Tips 6 **

    • redis 应用于具有操作先后顺序的数据控制
      • 微信朋友圈点赞,要求按照点赞顺序显示点赞好友信息;如果取消点赞,移除对应好友信息
  • **Tips 7 **

    • redis 应用于最新消息展示

      • twitter、新浪微博、腾讯微博中个人用户的关注列表需要按照用户的关注顺序进行展示,粉丝列表需要将最近关注的粉丝列在前面

      • 新闻、资讯类网站将最新的新闻或资讯按照发生的时间顺序展示

      • 企业运营过程中,系统将产生出大量的运营数据,保障多台服务器操作日志的统一顺序输出

        image-20210905021040556

5、set

1、set 类型

  • 新的存储需求:存储大量的数据,在查询方面提供更高的效率
  • 需要的存储结构:能够保存大量的数据,高效的内部存储机制,便于查询
  • set类型:与hash存储结构完全相同,仅存储键,不存储值(nil),并且值是不允许重复的(自动排重)
  • Redis的Set是string类型的无序集合。它底层其实是一个value为null的hash表,所以添加,删除,查找的==复杂度都是O(1)。==

image-20210905021259230

image-20210905021315084

2、Set 的数据结构

Set数据结构是dict字典,字典是用哈希表实现的。

  • Java中HashSet的内部实现使用的是HashMap,只不过所有的value都指向同一个对象。
  • Redis的set结构也是一样,它的内部也使用hash结构,所有的value都指向同一个内部值。

3、set 类型数据的基本操作

  • 添加数据

    1
    sadd key member1 [member2]
  • 获取全部数据

    1
    smembers key
  • 删除数据

    1
    srem key member1 [member2]
  • 获取集合数据总量

    1
    scard key
  • 判断集合中是否包含指定数据

    1
    sismember key member
  • 随机获取集合中指定数量的数据

    1
    srandmember key [count]
  • 随机获取集合中的某个数据并将该数据移出集合

    1
    spop key [count]
  • 求两个集合的交、并、差集

    1
    2
    3
    4
    5
    6
    7
    8
    # 交集
    sinter key1 [key2]

    # 交集
    sunion key1 [key2]

    # 差集(注意差集的key1与key2互换的话可能导致结果不同)
    sdiff key1 [key2]
  • 求两个集合的交、并、差集并存储到指定集合中

    1
    2
    3
    4
    5
    6
    7
    8
    # 交集
    sinterstore destination key1 [key2]

    # 交集
    sunionstore destination key1 [key2]

    # 差集(注意差集的key1与key2互换的话可能导致结果不同)
    sdiffstore destination key1 [key2]
  • 将指定数据从原始集合中==移动==到目标集合中

    1
    smove source destination member

4、set 类型数据操作的注意事项

  • set 类型不允许数据重复,如果添加的数据在 set 中已经存在,将只保留一份
  • set 虽然与hash的存储结构相同,但是无法启用hash中存储值的空间

5、set 类型应用场景

  • **Tips 8 **
    • redis 应用于随机推荐类信息检索,例如热点歌单推荐,热点新闻推荐,热卖旅游线路,应用APP推荐,大V推荐等
      • 每位用户首次使用今日头条时会设置3项爱好的内容,但是后期为了增加用户的活跃度、兴趣点,必须让用户对其他信息类别逐渐产生兴趣,增加客户留存度
  • **Tips 9 **
    • redis 应用于同类信息的关联搜索,二度关联搜索,深度关联搜索
      • 显示共同关注(一度)
      • 显示共同好友(一度)
      • 由用户A出发,获取到好友用户B的好友信息列表(一度)
      • 由用户A出发,获取到好友用户B的购物清单列表(二度)
      • 由用户A出发,获取到好友用户B的游戏充值列表(二度)
      • 脉脉为了促进用户间的交流,保障业务成单率的提升,需要让每位用户拥有大量的好友,事实上职场新人不具有更多的职场好友,如何快速为用户积累更多的好友?
      • 新浪微博为了增加用户热度,提高用户留存性,需要微博用户在关注更多的人,以此获得更多的信息或热门话题,如何提高用户关注他人的总量?
      • QQ新用户入网年龄越来越低,这些用户的朋友圈交际圈非常小,往往集中在一所学校甚至一个班级中,如何帮助用户快速积累好友用户带来更多的活跃度?
      • 微信公众号是微信信息流通的渠道之一,增加用户关注的公众号成为提高用户活跃度的一种方式,如何帮助用户积累更多关注的公众号?
      • 美团外卖为了提升成单量,必须帮助用户挖掘美食需求,如何推荐给用户最适合自己的美食?
  • **Tips 10 **
    • redis应用于同类型不重复数据的合并操作
      • 集团公司共具有12000名员工,内部OA系统中具有700多个角色,3000多个业务操作,23000多种数据,每位员工具有一个或多个角色,如何快速进行业务操作的权限校验?
  • **Tips 11 **
    • redis 应用于同类型数据的快速去重
      • 公司对旗下新的网站做推广,统计网站的PV(访问量),UV(独立访客),IP(独立IP)。
        • PV:网站被访问次数,可通过刷新页面提高访问量
        • UV:网站被不同用户访问的次数,可通过cookie统计访问量,相同用户切换IP地址,UV不变
        • IP:网站被不同IP地址访问的总次数,可通过IP地址统计访问量,相同IP不同用户访问,IP不变
  • **Tips 12 **
    • redis 应用于基于黑名单与白名单设定的服务控制
      • 黑名单
        • 资讯类信息类网站追求高访问量,但是由于其信息的价值,往往容易被不法分子利用,通过爬虫技术,快速获取信息,个别特种行业网站信息通过爬虫获取分析后,可以转换成商业机密进行出售。例如第三方火车票、机票、酒店刷票代购软件,电商刷评论、刷好评。
        • 同时爬虫带来的伪流量也会给经营者带来错觉,产生错误的决策,有效避免网站被爬虫反复爬取成为每个网站都要考虑的基本问题。在基于技术层面区分出爬虫用户后,需要将此类用户进行有效的屏蔽,这就是黑名单的典型应用。
        • ps:不是说爬虫一定做摧毁性的工作,有些小型网站需要爬虫为其带来一些流量。
      • 白名单
        • 对于安全性更高的应用访问,仅仅靠黑名单是不能解决安全问题的,此时需要设定可访问的用户群体,依赖白名单做更为苛刻的访问验证。

6、sorted_set

1、sorted_set 类型

  • 新的存储需求:数据排序有利于数据的有效展示,需要提供一种可以根据自身特征进行排序的方式
  • 需要的存储结构:新的存储模型,可以保存可排序的数据
  • sorted_set类型:在set的存储结构基础上添加可排序字段
  • 集合的成员是唯一的,但是评分可以是重复了 。

image-20210905023025448

2、Sorted_set 的数据结构

Sorted_set(zset)是Redis提供的一个非常特别的数据结构:

  1. 一方面它等价于Java的数据结构Map<String, Double>,可以给每一个元素value赋予一个权重score;
  2. 另一方面它又类似于TreeSet,内部的元素会按照权重score进行排序,可以得到每个元素的名次,还可以通过score的范围来获取元素的列表。

zset底层使用了两个数据结构:

  1. hash,hash的作用就是关联元素value和权重score,保障元素value的唯一性,可以通过元素value找到相应的score值。
  2. 跳跃表,跳跃表的目的在于给元素value排序,根据score的范围获取元素列表
跳跃表(跳表)
1、简介

有序集合在生活中比较常见,例如根据成绩对学生排名,根据得分对玩家排名等。

对于有序集合的底层实现,可以用:

  • 数组
    • 数组不便元素的插入、删除
  • 平衡树
    • 平衡树或红黑树虽然效率高但结构复杂
  • 链表
    • 链表查询需要遍历所有效率低。

Redis采用的是跳跃表。跳跃表效率堪比红黑树,实现远比红黑树简单。

2、实例

对比有序链表和跳跃表,从链表中查询出51

(1) 有序链表

image-20210907225230388

要查找值为51的元素,需要从第一个元素开始依次查找、比较才能找到。共需要6次比较

(2) 跳跃表

image-20210907225311811

  1. 从第2层开始,1节点比51节点小,向后比较。
  2. 21节点比51节点小,继续向后比较,后面就是NULL了,所以从21节点向下到第1层
  3. 在第1层,41节点比51节点小,继续向后,61节点比51节点大,所以从41向下
  4. 在第0层,51节点为要查找的节点,节点被找到,共查找4次

从此可以看出跳跃表比有序链表效率要高

3、sorted_set 类型数据的基本操作

  • 添加数据

    1
    zadd key score1 member1 [score2 member2]
  • 获取全部数据

    1
    2
    3
    4
    5
    6
    # 从start到stop顺序获取key当中的数据
    # WITHSCORES 获取key的member的同时获取member的scores
    zrange key start stop [WITHSCORES]

    # 从start到stop逆序获取key当中的数据
    zrevrange key start stop [WITHSCORES]
  • 删除数据

    1
    zrem key member [member ...]
  • 按条件获取数据

    1
    2
    3
    4
    5
    # min与max用于限定搜索查询的条件
    # LIMIT 与mysql的LIMIT用法一样,用来限制数据的数量
    zrangebyscore key min max [WITHSCORES] [LIMIT]

    zrevrangebyscore key max min [WITHSCORES]
  • 条件删除数据

    1
    2
    3
    zremrangebyrank key start stop

    zremrangebyscore key min max
  • 获取集合数据总量

    1
    2
    3
    zcard key

    zcount key min max

    注意:

    • min与max用于限定搜索查询的条件
    • start与stop用于限定查询范围,作用于索引,表示开始和结束索引
    • offset与count用于限定查询范围,作用于查询结果,表示开始位置和数据总量
  • 集合交、并操作

    1
    2
    3
    4
    5
    6
    7
    # 求最大最小值
    # zinterstore sss 3 s1 s2 s3 agggregate max/min
    zinterstore destination numkeys key [key ...]

    # 求公共部分的和
    # zinterstore ss 3 s1 s2 s3
    zunionstore destination numkeys key [key ...]
  • 获取数据对应的索引(排名)

    1
    2
    3
    zrank key member

    zrevrank key member
  • score值获取与修改

    1
    2
    3
    zscore key member

    zincrby key increment member
  • 获取当前系统时间

    1
    2
    3
    # score 秒
    # member 毫秒
    time

4、sorted_set 类型数据操作的注意事项

  • score保存的数据存储空间是64位,如果是整数范围是-9007199254740992~9007199254740992
  • score保存的数据也可以是一个双精度的double值,基于双精度浮点数的特征,可能会丢失精度,使用时候要慎重
  • sorted_set 底层存储还是基于set结构的,因此数据不能重复如果重复添加相同的数据,score值将被反复覆盖,保留最后一次修改的结果

5、sorted_set 类型应用场景

  • **Tips 13 **
    • redis 应用于计数器组合排序功能对应的排名
      • 票选广东十大杰出青年,各类综艺选秀海选投票
      • 各类资源网站TOP10(电影,歌曲,文档,电商,游戏等)
      • 聊天室活跃度统计
      • 游戏好友亲密度
  • **Tips 14 **
    • redis 应用于定时任务执行顺序管理或任务过期管理
      • 基础服务+增值服务类网站会设定各位会员的试用,让用户充分体验会员优势。例如观影试用VIP、游戏VIP体验、云盘下载体验VIP、数据查看体验VIP。当VIP体验到期后,如果有效管理此类信息。即便对于正式VIP用户也存在对应的管理方式。
      • 网站会定期开启投票、讨论,限时进行,逾期作废。如何有效管理此类过期信息。
  • **Tips 15 **
    • redis 应用于即时任务/消息队列执行管理
      • 任务/消息权重设定应用:
        • 当任务或者消息待处理,形成了任务队列或消息队列时,对于高优先级的任务要保障对其优先处理,如何实现任务权重管理。

7、数据类型实践案例

1、业务场景1

人工智能领域的语义识别与自动对话将是未来服务业机器人应答呼叫体系中的重要技术,百度自研用户评价语义识别服务,免费开放给企业试用,同时训练百度自己的模型。现对试用用户的使用行为进行限速,限制每个用户每分钟最多发起10次调用

image-20210905024857928

2、解决方案

  • 设计计数器,记录调用次数,用于控制业务执行次数。以用户id作为key,使用次数作为value
  • 在调用前获取次数,判断是否超过限定次数
    • 不超过次数的情况下,每次调用计数+1
    • 业务调用失败,计数-1
  • 为计数器设置生命周期为指定周期,例如1秒/分钟,自动清空周期内使用次数

image-20210905024953197

3、解决方案改良

  • 取消最大值的判定,利用incr操作超过最大值抛出异常的形式替代每次判断是否大于最大值
  • 判断是否为nil,
    • 如果是,设置为Max-次数
    • 如果不是,计数+1
    • 业务调用失败,计数-1
  • 遇到异常即+操作超过上限,视为使用达到上限

image-20210905025121657

**Tips 16 **

  • redis 应用于限时按次结算的服务控制

4、业务场景2

使用微信的过程中,当微信接收消息后,会默认将最近接收的消息置顶,当多个好友及关注的订阅号同时发送消息时,该排序会不停的进行交替。同时还可以将重要的会话设置为置顶。一旦用户离线后,再次打开微信时,消息该按照什么样的顺序显示?

5、业务分析

image-20210905025305653

6、解决方案

  • 依赖list的数据具有顺序的特征对消息进行管理,将list结构作为栈使用
  • 对置顶与普通会话分别创建独立的list分别管理
  • 当某个list中接收到用户消息后,将消息发送方的id从list的一侧加入list(此处设定左侧)
  • 多个相同id发出的消息反复入栈会出现问题,在入栈之前无论是否具有当前id对应的消息,先删除对应id
  • 推送消息时先推送置顶会话list,再推送普通会话list,推送完成的list清除所有数据
  • 消息的数量,也就是微信用户对话数量采用计数器的思想另行记录,伴随list操作同步更新

**Tips 17 **

redis 应用于基于时间顺序的数据操作,而不关注具体时间

8、解决方案列表

  1. Tips 1:redis用于控制数据库表主键id,为数据库表主键提供生成策略,保障数据库表的主键唯一性
  2. Tips 2:redis 控制数据的生命周期,通过数据是否失效控制业务行为,适用于所有具有时效性限定控制的操作
  3. Tips 3:redis应用于各种结构型和非结构型高热度数据访问加速
  4. Tips 4:redis 应用于购物车数据存储设计
  5. Tips 5:redis 应用于抢购,限购类、限量发放优惠卷、激活码等业务的数据存储设计
  6. Tips 6:redis 应用于具有操作先后顺序的数据控制
  7. Tips 7:redis 应用于最新消息展示
  8. Tips 8:redis 应用于随机推荐类信息检索,例如热点歌单推荐,热点新闻推荐,热卖旅游线路,应用APP推荐,大V推荐等
  9. Tips 9:redis 应用于同类信息的关联搜索,二度关联搜索,深度关联搜索
  10. Tips 10:redis 应用于同类型不重复数据的合并、取交集操作
  11. Tips 11:redis 应用于同类型数据的快速去重
  12. Tips 12:redis 应用于基于黑名单与白名单设定的服务控制
  13. Tips 13:redis 应用于计数器组合排序功能对应的排名
  14. Tips 14:redis 应用于定时任务执行顺序管理或任务过期管理
  15. Tips 15:redis 应用于及时任务/消息队列执行管理
  16. Tips 16:redis 应用于按次结算的服务控制
  17. Tips 17:redis 应用于基于时间顺序的数据操作,而不关注具体时间

3、Redis 通用指令

1、key通用指令

1、key 特征

  • key是一个字符串,通过key获取redis中保存的数据
  • key应该设计哪些操作?
    • 对于key自身状态的相关操作,例如:删除,判定存在,获取类型等
    • 对于key有效性控制相关操作,例如:有效期设定,判定是否有效,有效状态的切换等
    • 对于key快速查询操作,例如:按指定策略查询key
    • ……

2、key 基本操作

  • 删除指定key

    1
    del key
  • 根据value选择非阻塞删除

    1
    unlink key

    仅将keys从keyspace元数据中删除,真正的删除会在后续异步操作。

    • 惰性删除lazyfree的机制,它可以将删除键或数据库的操作放在后台线程里执行,删除对象时只是进行逻辑删除,从而尽可能地避免服务器阻塞。
  • 获取key是否存在

    1
    exists key
  • 获取key的类型

    1
    type key

3、key 扩展操作

1、key 扩展操作——时效性控制
  • 为指定key设置有效期

    1
    2
    3
    4
    5
    6
    7
    # 设置的是时间
    expire key seconds
    pexpire key milliseconds

    # 设置的是时间戳
    expireat key timestamp
    pexpireat key milliseconds-timestamp
  • 获取key的有效时间

    1
    2
    3
    4
    5
    6
    # time to live
    # 不存在返回-2
    # 存在返回-1(永久)
    # 存在并且设置了有效期(返回有效时间)
    ttl key
    pttl key
  • 切换key从时效性转换为永久性

    1
    persist key
2、key 扩展操作——查询模式
  • 查询key

    1
    keys pattern

查询模式规则

  • *:匹配任意数量的任意符号
  • ?:配合一个任意符号
  • []:匹配一个指定符号

image-20210905030341819

4、key 其他操作

  • 为key改名

    1
    2
    3
    4
    5
    # 如果修改的名称在redis当中存在,则会进行覆盖(将里面的内容进行覆盖)
    # 解决方法:renamenx(如果存在则改名失败)
    rename key newkey

    renamenx key newkey
  • 对所有key排序

    1
    2
    # 只是排序,不动元数据存储的顺序
    sort
  • 其他key通用操作

    1
    help @generic

2、数据库通用指令

1、数据库

key 的重复问题

  • key是由程序员定义的
  • redis在使用过程中,伴随着操作数据量的增加,会出现大量的数据以及对应的key
  • 数据不区分种类、类别混杂在一起,极易出现重复或冲突

解决方案

  • redis为每个服务提供有16个数据库,编号从0到15
    • 默认使用的是第0号数据库
  • 每个数据库之间的数据相互独立
    • 这些数据库共用一块空间

image-20210905030731903

2、db 基本操作

  • 切换数据库

    1
    select index
  • 其他操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    quit

    # PONG
    # 进行数据回显,测试海外是否连通
    ping

    # 给redis控制台输出日志
    # eg:
    # 127.0.0.1:6379> echo abc
    # 127.0.0.1:6379> "abc"
    echo message
  • 数据移动

    1
    move key db
    1. move相当于剪切操作
    2. 如果原数据库没有数据,move失败
    3. 如果目标数据库已存在数据,move失败
    4. 注意:
      • 进行move操作的是原数据库
      • 数据移动的数据库是目标数据库
  • 数据清除

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 检查当前数据库有多少个key
    dbsize

    # 清除当前数据库的所有数据
    flushdb

    # 清除redis当中所有数据(最强大的一个命令,慎用)
    # 执行flushall命令,也会产生dump.rdb文件,但里面是空的,无意义
    flushall

3、常用服务器命令

  • 检验连接状态

    1
    2
    #如果连接成功返回PONG,连接失败返回错误信息
    PING

    img

  • 验证密码是否正确

    1
    auth password

    img

  • 查看服务器信息

    1
    INFO [section]

    img

  • 查看配置信息

    1
    config get patten

    img

  • 修改当前配置信息

    Config Set 命令可以动态地调整 Redis 服务器的配置(configuration)而无须重启,但此时配置文件中仍是修改前的配置,可搭配config rewrite命令一起使用:

    1
    CONFIG SET parameter value

    img

  • 重写配置文件

    Config rewrite 命令对启动 Redis 服务器时所指定的 redis.conf 配置文件进行改写。与config set不同,set之后会将配置信息修改而无需重启服务,但此时redis.conf配置文件里记录的参数仍是set之前的值,如果将redis服务重启后会读取conf文件中的配置,这时候读到的还是set之前的配置,因此我们可以在set配置之后使用rewrite命令将当前的配置回写至配置文件内,这样就能不停机修改配置信息了,因此config set和config rewrite是配合使用的:

    1
    CONFIG REWRITE

    img

  • 重置统计信息

    • 使用Config Resetstat 命令重置 INFO 命令中的某些统计数据,包括:
      • Keyspace hits (键空间命中次数)
      • Keyspace misses (键空间不命中次数)
      • Number of commands processed (执行命令的次数)
      • Number of connections received (连接服务器的次数)
      • Number of expired keys (过期key的数量)
      • Number of rejected connections (被拒绝的连接数量)
      • Latest fork(2) time(最后执行 fork(2) 的时间)
      • The aof_delayed_fsync counter(aof_delayed_fsync 计数器的值)
    1
    CONFIG RESETSTAT
  • 获取当前时间

    Time 命令用于返回当前服务器时间,返回一个包含两个字符串的列表: 第一个字符串是当前时间(以 UNIX 时间戳格式表示),而第二个字符串是当前这一秒钟已经逝去的微秒数。

    1
    time

    img

  • DeBug

    debug object key获取 key 的调试信息,当key不存在时返回错误信息。

    debug segfault 命令执行一个非法的内存访问从而让 Redis 崩溃,仅在开发时用于 BUG 调试,执行后需要重启服务。

    1
    2
    debug object key
    debug segfault

    img

  • 查看当前Redis中所有可用命令

    • 使用Command 命令用于返回所有的Redis命令的详细信息,以数组形式展示:

      1
      command
    • 使用command count命令查看当前Redis中命令的数量:

      1
      command count
    • 使用command info命令查看当前Redis中指定的命令的详细信息:

      1
      COMMAND INFO command-name [command-name ...]

    img

  • 彩蛋

    Redis5之后新增的彩蛋,使用LOLWUT命令即可返回一副随机图像以及当前redis的版本信息。事实上LOLWUT没有任何作用,但它想告诉我们的是:”编程不仅仅是把一些代码放在一起创建有用的东西,也可以是无用但有趣的。

    1
    LOLWUT

    img


4、Jedis

1、Jedis简介

编程语言与redis:

  • Java语言连接redis服务
    • Jedis
    • SpringData Redis
    • Lettuce
  • C 、C++ 、C# 、Erlang、Lua 、Objective-C 、Perl 、PHP 、Python 、Ruby 、Scala
  • 可视化连接redis客户端
    • Redis Desktop Manager
    • Redis Client
    • Redis Studio

2、HelloWorld(Jedis版)

1、准备工作

  • jar包导入

  • 基于maven

    1
    2
    3
    4
    5
    <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
    </dependency>

2、客户端连接redis

  • API文档

  • 连接redis

    1
    Jedis jedis = new Jedis("localhost", 6379);
  • 操作redis(jedis的API与redis的命令是一样的)

    1
    2
    jedis.set("name", "itheima");
    jedis.get("name");
  • 关闭redis连接

    1
    jedis.close();

3、Jedis简易工具类开发

1、基于连接池获取连接

  • JedisPool:Jedis提供的连接池技术
    • poolConfig:连接池配置对象
    • host:redis服务地址
    • port:redis服务端口号
1
2
3
4
5
6
/**
* JedisPool(org.apache.commons.pool2.impl.GenericObjectPoolConfig poolConfig, String host, int port, int timeout, String password, int database, String clientName)
*/
public JedisPool(GenericObjectPoolConfig poolConfig, String host, int port) {
this(poolConfig, host, port, 2000, (String)null, 0, (String)null);
}

2、封装连接参数

jedis.properties:

1
2
3
4
jedis.host=localhost
jedis.port=6379
jedis.maxTotal=30
jedis.maxIdle=10

3、加载配置信息

静态代码块初始化资源:

1
2
3
4
5
6
7
8
9
10
11
12
static{
//读取配置文件 获得参数值
ResourceBundle rb = ResourceBundle.getBundle("jedis");
host = rb.getString("jedis.host");
port = Integer.parseInt(rb.getString("jedis.port"));
maxTotal = Integer.parseInt(rb.getString("jedis.maxTotal"));
maxIdle = Integer.parseInt(rb.getString("jedis.maxIdle"));
poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(maxTotal);
poolConfig.setMaxIdle(maxIdle);
jedisPool = new JedisPool(poolConfig,host,port);
}

4、加载配置信息

对外访问接口,提供jedis连接对象,连接从连接池获取:

1
2
3
4
public static Jedis getJedis(){
Jedis jedis = jedisPool.getResource();
return jedis;
}

4、可视化客户端

Redis Desktop Manager:

image-20210905032008447


2、Redis高级(Linux)

1、基于Linux环境安装Redis

1、Redis在Linux环境下的安装

  • 下载安装包

    1
    wget http://download.redis.io/releases/redis-?.?.?.tar.gz
  • 解压

    1
    tar –xvf 文件名.tar.gz
  • 编译

    1
    make
  • 安装

    1
    make install [destdir=/目录]

2、Redis基础环境设置

  • 创建软链接

    1
    ln -s 原始目录名 快速访问目录名
  • 创建配置文件管理目录

    1
    2
    3
    mkdir conf
    # 或者
    mkdir config
  • 创建数据文件管理目录

    1
    mkdir data

3、Redis服务启动

  • 默认配置启动

    1
    2
    3
    redis-server
    redis-server –-port 6379
    redis-server –-port 6380 ……
  • 指定配置文件启动

    1
    2
    3
    4
    5
    redis-server redis.conf
    redis-server redis-6379.conf
    redis-server redis-6380.conf ……
    redis-server conf/redis-6379.conf
    redis-server config/redis-6380.conf ……
  • 开机自启动

    • 注册服务:

      1
      vim /lib/systemd/system/redis.service
    • 配置文件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      [Unit]
      Description=Redis
      After=network.target

      [Service]
      Type=forking
      PIDFile=/var/run/redis_6379.pid
      ExecStart=/opt/app/redis6/bin/redis-server /opt/app/redis6/bin/redis.conf
      ExecReload=/bin/kill -s HUP $MAINPID
      ExecStop=/bin/kill -s QUIT $MAINPID
      PrivateTmp=true

      [Install]
      WantedBy=multi-user.target
    • 使用systemctl命令:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      # 重载服务
      systemctl daemon-reload
      # 开机自启
      systemctl enable redis
      # 启动
      systemctl start redis

      # 重启
      systemctl restart redis   

      # 停止
      systemctl stop redis
      # 查看状态
      systemctl status redis

      # 关闭开机启动
      systemctl disable redis

      img

4、Redis客户端连接

  • 默认连接

    1
    redis-cli

    其中加上–raw可以防止中文乱码

    1
    redis-cli --raw
  • 连接指定服务器

    1
    2
    3
    redis-cli -h 127.0.0.1
    redis-cli –port 6379
    redis-cli -h 127.0.0.1 –port 6379

5、Redis服务端配置

  • 基本配置

    • 以守护进程方式启动,使用本启动方式,redis将以服务的形式存在,日志将不再打印到命令窗口中

      1
      daemonize yes
    • 取消绑定ip,监听所有IP

      1
      2
      # 把这一行注释,监听所有IP
      #bind 127.0.0.1
    • 开启保护模式

      1
      2
      # protected-mode yes 如果改为no,则是关闭保护模式,这种模式下不能配置系统服务,建议还是开启
      protected-mode yes
    • 设定当前服务启动端口号

      1
      port 6***
    • 设定当前服务文件保存位置,包含日志文件、持久化文件(后面详细讲解)等

      1
      dir "/自定义目录/redis/data"
    • 设定日志文件名,便于查阅

      1
      logfile "6***.log"

2、Redis 持久化

1、持久化简介

1、什么是持久化

利用永久性存储介质将数据进行保存,在特定的时间将保存的数据进行恢复的工作机制称为持久化。

2、为什么要进行持久化

防止数据的意外丢失,确保数据安全性

3、持久化过程保存什么

  • 当前数据状态进行保存,快照形式,存储数据结果,存储格式简单,关注点在数据

    image-20210905174735216

  • 数据的操作过程进行保存,日志形式,存储操作过程,存储格式复杂,关注点在数据的操作过程

    image-20210905174748200

2、RDB

1、RDB普通启动方式

1、RDB启动方式 —— save指令
1、RDB启动方式

谁,什么时间,干什么事情

命令执行:

  • 谁:redis操作者(用户)
  • 什么时间:即时(随时进行)
  • 干什么事情:保存数据
2、RDB启动方式 —— save指令
  • 命令

    1
    save
  • 作用:手动执行一次保存操作

3、RDB启动方式 —— save指令相关配置
  • dbfilename dump.rdb
    
    1
    2
    3
    4
    5
    6

    - 说明:**设置本地数据库文件名,默认值为 dump.rdb**
    - 经验:通常设置为==dump-端口号.rdb==,方便查看

    - ```sh
    dir
    - 说明:**设置存储.rdb文件的路径** - 经验:通常设置成存储空间较大的目录中,==目录名称data==
  • rdbcompression yes
    
    1
    2
    3
    4
    5
    6

    - 说明:**设置存储至本地数据库时是否压缩数据,默认为 yes**,采用 `LZF 压缩`
    - 经验:通常默认为开启状态,如果设置为no,可以节省 CPU 运行时间,但会使存储的文件变大(巨大)

    - ```sh
    rdbchecksum yes
    - 说明:**设置是否进行RDB文件格式校验,该校验过程在写文件和读文件过程均进行**(让redis使用CRC64算法来进行数据校验) - 经验:**通常默认为开启状态**,如果设置为no,可以节约读写性过程约10%时间消耗,但是==存储一定的数据损坏风险==
4、RDB启动方式 —— save指令工作原理

image-20210905175219504

image-20210905175236464

注意:save指令的执行会阻塞当前Redis服务器,直到当前RDB过程完成为止,有可能会造成长时间阻塞,线上环境不建议使用

2、RDB启动方式 —— bgsave指令
1、RDB启动方式

数据量过大,单线程执行方式造成效率过低如何处理?

后台执行:

  • 谁:redis操作者(用户)发起指令;redis服务器控制指令执行
  • 什么时间:即时(发起);合理的时间(执行)
  • 干什么事情:保存数据
2、RDB启动方式 —— bgsave指令
  • 命令

    1
    bgsave
  • 作用:手动启动后台保存操作,但不是立即执行

3、RDB启动方式 —— bgsave指令工作原理

image-20210905175450021

  1. Redis会单独创建(fork)一个子进程来进行持久化;
  2. Redis会先将数据写入到 一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。
  3. 整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能 如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那RDB方式要比AOF方式更加的高效。

关于fork:

  1. Fork的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等) 数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程
  2. 在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,Linux中引入了“写时复制技术
  3. 一般情况父进程和子进程会共用同一段物理内存,只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。

注意:

  • bgsave命令是针对save阻塞问题做的优化。
  • Redis内部所有涉及到RDB操作都采用bgsave的方式
  • save命令可以放弃使用。
4、RDB启动方式 —— bgsave指令相关配置
  • dbfilename dump.rdb

  • dir

  • rdbcompression yes

  • rdbchecksum yes

  • stop-writes-on-bgsave-error yes
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22

    - 说明:**后台存储过程中如果出现错误现象,是否停止保存操作**
    - 经验:通常**默认为开启状态**

    ##### 3、RDB启动方式 ——save配置

    ###### 1、RDB启动方式

    反复执行保存指令,忘记了怎么办?不知道数据产生了多少变化,何时保存?

    自动执行:

    - 谁:redis服务器发起指令(基于条件)
    - 什么时间:满足条件
    - 干什么事情:保存数据

    ###### 2、RDB启动方式 ——save配置

    - 配置

    ```sh
    save second changes
    底层使用了`bgsave`指令
  • 作用:满足限定时间范围内key的变化数量达到指定数量即进行持久化

  • 参数

    • second:监控时间范围
    • changes:监控key的变化量
  • 位置:在conf文件中进行配置

  • 范例:

    1
    2
    3
    save 900 1
    save 300 10
    save 60 10000

    注意:

    • 一般second与changes两个值的设置差别会比较大,要不就前小后大,要不就前大后小。具体看相关的业务。
    • 两个值差别不大的话,设置没什么意义。
3、RDB启动方式 ——save配置原理

image-20210905180056763

注意:

  • save配置要根据实际业务情况进行设置,频度过高或过低都会出现性能问题,结果可能是灾难性的
  • save配置中对于second与changes设置通常具有互补对应关系,尽量不要设置成包含性关系
  • save配置启动后执行的是bgsave操作
4、save配置相关配置
  • dbfilename dump.rdb
  • dir
  • rdbcompression yes
  • rdbchecksum yes

2、RDB的备份

  1. 先通过config get dir 查询rdb文件的目录
  2. 将*.rdb的文件拷贝到别的地方
    1. rdb的恢复
      1. 关闭Redis
      2. 先把备份的文件拷贝到工作目录下 cp dump2.rdb dump.rdb
      3. 启动Redis, 备份数据会直接加载

3、RDB的停止

动态停止RDB:

1
2
#save后给空值,表示禁用保存策略
redis-cli config set save ""

4、RDB三种启动方式对比

方式 save指令 bgsave指令
读写 同步 异步
阻塞客户端指令
额外内存消耗
启动新进程

注:由于替换save配置启动RDB在底层也是调用了bgsave指令,所以这里不做展示。

5、RDB特殊启动形式

  • 全量复制

    • 在主从复制中详细讲解
  • 服务器运行过程中重启

    1
    debug reload
  • 关闭服务器时指定保存数据

    1
    shutdown save

    默认情况下执行shutdown命令时,自动执行bgsave(如果没有开启AOF持久化功能)

6、RDB优缺点

1、RDB优点
  1. RDB是一个==紧凑压缩的二进制文件,存储效率较高==
  2. RDB内部存储的是redis在==某个时间点==的数据快照,非常适合用于数据备份,全量复制等场景
  3. RDB==恢复数据==的速度要比AOF==快==很多
  4. 应用:服务器中每X小时执行bgsave备份,并将RDB文件拷贝到远程机器中,==用于灾难恢复==。
2、RDB缺点
  1. RDB方式无论是执行指令还是利用配置,==无法做到实时持久化,具有较大的可能性丢失数据==
  2. bgsave指令==每次运行要执行fork操作创建子进程,内存中的数据被克隆了一份,大致2倍的膨胀性需要考虑,要牺牲掉一些性能==
  3. 虽然Redis在fork时使用了写时拷贝技术,但是如果数据庞大时还是比较消耗性能。
  4. Redis的==众多版本中未进行RDB文件格式的版本统一,有可能出现各版本服务之间数据格式无法兼容现象==
  5. 在备份周期在一定间隔时间做一次备份,所以如果Redis意外down掉的话,就会丢失最后一次快照后的所有修改。

关于第四缺点的相关说明:

  • redis2.0的RDB文件不能恢复成redis4.0的数据

一个解决方法:(不得已的方法)

  1. 先将redis2.0的RDB文件恢复成redis2.0的数据;
  2. 在将数据存储到数据库当中;
  3. 最后将数据库作为数据源将数据恢复成redis4.0的数据,并生成redis4.0的RDB文件

3、AOF

1、RDB存储的弊端

  • 存储数据量较大,效率较低
    • 基于快照思想,每次读写都是全部数据,当数据量巨大时,效率非常低
  • 大数据量下的IO性能较低
  • 基于fork创建子进程,内存产生额外消耗
  • 宕机带来的数据丢失风险

解决思路:

  • 不写全数据,仅记录部分数据
  • 降低区分数据是否改变的难度,改记录数据为记录操作过程
  • 对所有操作均进行记录,排除丢失数据的风险

2、AOF概念

  • AOF(append only file)持久化:
    • 以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中命令达到恢复数据的目的。
    • 与RDB相比可以简单描述为改记录数据为记录数据产生的过程
  • AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式

3、AOF写数据过程

image-20210905181851353

  1. 客户端的请求写命令会被append追加到AOF缓冲区内;
  2. AOF缓冲区根据AOF持久化策略[always,everysec,no]将操作sync同步到磁盘的AOF文件中;
  3. AOF文件大小超过重写策略或手动重写时,会对AOF文件rewrite重写,压缩AOF文件容量;
  4. Redis服务重启时,会重新load加载AOF文件中的写操作达到数据恢复的目的;

4、AOF写数据三种策略(appendfsync)

  • always(每次):
    • 每次写入操作均同步到AOF文件中,==数据零误差,性能较低==,不建议使用
  • everysec(每秒):
    • 每秒将缓冲区中的指令同步到AOF文件中,==数据准确性较高,性能较高==,建议使用,也是默认配置
    • 在系统突然==宕机的情况下丢失1秒内的数据==
  • no(系统控制):
    • 由操作系统控制每次同步到AOF文件的周期,==整体过程不可控==

5、AOF功能开启

  • 配置

    1
    appendonly yes|no
  • 作用:是否开启AOF持久化功能,默认为不开启状态

  • 配置

    1
    appendfsync always|everysec|no
  • 作用:AOF写数据策略

6、AOF相关配置

  • 配置

    1
    appendfilename filename
  • 作用:

    • AOF持久化文件名,默认文件名为appendonly.aof
    • **建议配置为appendonly-端口号.aof**,方便查看
  • 配置

    1
    dir
  • 作用:AOF持久化文件保存路径,与RDB持久化文件保持一致即可

7、AOF写数据遇到的问题

如果连续执行如下指令该如何处理?

image-20210905183132934

8、AOF重写

  • 随着命令不断写入AOF,文件会越来越大,为了解决这个问题,Redis引入了AOF重写机制压缩文件体积。

  • AOF文件重写是将Redis进程内的数据转化为写命令同步到新AOF文件的过程。

  • 简单说就是将对同一个数据的若干个条命令执行结果转化成最终结果数据对应的指令进行记录

9、AOF重写作用

  • 降低磁盘占用量,提高磁盘利用率
  • 提高持久化效率,降低持久化写时间,提高IO性能
  • 降低数据恢复用时,提高数据恢复效率

10、AOF重写规则

  • 进程内已超时的数据不再写入文件
  • 忽略无效指令,重写时使用进程内数据直接生成,这样新的AOF文件只保留最终数据的写入命令
    • 如del key1、 hdel key2、srem key3、set key4 111、set key4 222等
  • 对同一数据的多条写命令合并为一条命令
    • 如lpush list1 a、lpush list1 b、 lpush list1 c 可以转化为:lpush list1 a b c。
    • 为防止数据量过大造成客户端缓冲区溢出,对list、set、hash、zset等类型,每条指令最多写入64个元素

11、AOF重写方式

  • 手动重写

    1
    bgrewriteaof
  • 自动重写

    1
    2
    auto-aof-rewrite-min-size size
    auto-aof-rewrite-percentage percentage

12、AOF手动重写 —— bgrewriteaof指令工作原理

image-20210905183605904

AOF文件持续增长而过大时,会fork出一条新进程来将文件重写(也是先写临时文件最后再rename),redis4.0版本后的重写,是指上就是把rdb 的快照,以二级制的形式附在新的aof头部,作为已有的历史数据,替换掉原来的流水账操作

1
no-appendfsync-on-rewrite=yes
  • 如果 no-appendfsync-on-rewrite=yes,不写入aof文件只写入缓存,用户请求不会阻塞,但是在这段时间如果宕机会丢失这段时间的缓存数据。(降低数据安全性,提高性能)
  • 如果 no-appendfsync-on-rewrite=no,还是会把数据往磁盘里刷,但是遇到重写操作,可能会发生阻塞。(数据安全,但是性能降低)

13、AOF自动重写方式

  • 自动重写触发条件设置

    1
    2
    3
    4
    5
    # 设置重写的基准值,最小文件64MB。达到这个值开始重写。
    auto-aof-rewrite-min-size size

    # 设置重写的基准值,文件达到100%时开始重写(文件是原来重写后文件的2倍时触发)
    auto-aof-rewrite-percentage percent
  • 自动重写触发比对参数( 运行指令info Persistence获取具体信息)

    1
    2
    aof_current_size
    aof_base_size
  • 自动重写触发条件

    image-20210905184840300

  • 列出当前redis的所有的运行属性值

    1
    info

AOF什么时候会自动重写?

  • Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍文件大于64M时触发
  • 重写虽然可以节约大量磁盘空间,减少恢复时间。但是每次重写还是有一定的负担的,因此设定Redis要满足一定条件才会进行重写。

例如:文件达到70MB开始重写,降到50MB,下次什么时候开始重写?100MB

系统载入时或者上次重写完毕时,Redis会记录此时AOF大小,设为base_size,如果Redis的AOF当前大小 >= base_size +base_size*100% (默认)且当前大小>=64mb(默认)的情况下,Redis会对AOF进行重写。

14、AOF工作流程

image-20210905184924236

15、AOF重写流程

image-20210905185022041

image-20210905185033618

  1. bgrewriteaof触发重写,判断是否当前有bgsave或bgrewriteaof在运行,如果有,则等待该命令结束后再继续执行。
  2. 主进程fork出子进程执行重写操作,保证主进程不会阻塞。
  3. 子进程遍历redis内存中数据到临时文件,客户端的写请求同时写入aof_buf缓冲区和aof_rewrite_buf重写缓冲区保证原AOF文件完整以及新AOF文件生成期间的新的数据修改动作不会丢失。
  4. 子进程写完新的AOF文件后,向主进程发信号,父进程更新统计信息。
  5. 主进程把aof_rewrite_buf中的数据写入到新的AOF文件。
  6. 使用新的AOF文件覆盖旧的AOF文件,完成AOF重写。

16、系统调用write和fsync说明

AOF缓冲区同步文件策略,由参数appendfsync控制

系统调用write和fsync说明:

  • write操作会触发延迟写(delayed write)机制,Linux在内核提供页缓冲区用来提高硬盘IO性能。
    • write操作在写入系统缓冲区后直接返回。
    • 同步硬盘操作依赖于系统调度机制,列如:缓冲区页空间写满或达到特定时间周期。
    • 同步文件之前,如果此时系统故障宕机,缓冲区内数据将丢失。
  • fsync针对单个文件操作(比如AOF文件),做强制硬盘同步,fsync将阻塞知道写入硬盘完成后返回,保证了数据持久化。

除了write、fsync、Linx还提供了sync、fdatasync操作,具体参见API说明。

17、AOF的优缺点

1、优点
  • 备份机制更稳健,丢失数据概率更低。
  • 可读的日志文本,通过操作AOF稳健,可以处理误操作。
2、缺点
  • 比起RDB占用更多的磁盘空间。
  • 恢复备份速度要慢。
  • 每次读写都同步的话,有一定的性能压力。
  • 存在个别Bug,造成恢复不能。

4、RDB与AOF区别

1、RDB VS AOF

持久化方式 RDB AOF
占用存储空间 小(数据级:压缩) 大(指令级:重写)
存储速度
恢复速度
数据安全性 会丢失数据 依据策略决定
资源消耗 高/重量级 低/轻量级
启动优先级

2、RDB与AOF的选择之惑

  • 对数据非常敏感,建议使用默认的AOF持久化方案
    • AOF持久化策略使用everysecond,每秒钟fsync一次。该策略redis仍可以保持很好的处理性能,当出现问题时,最多丢失0-1秒内的数据。
    • 注意:由于AOF文件存储体积较大,且恢复速度较慢
  • 数据呈现阶段有效性,建议使用RDB持久化方案
    • 数据可以良好的做到阶段内无丢失(该阶段是开发者或运维人员手工维护的),且恢复速度较快,阶段点数据恢复通常采用RDB方案
    • 注意:利用RDB实现紧凑的数据持久化会使Redis降的很低,慎重总结:
  • 综合比对
    • RDB与AOF的选择实际上是在做一种权衡,每种都有利有弊
    • 如不能承受数分钟以内的数据丢失,对业务数据非常敏感,选用AOF
    • 如能承受数分钟以内的数据丢失,且追求大数据集的恢复速度,选用RDB
    • 灾难恢复选用RDB
    • 双保险策略,同时开启 RDB 和 AOF,重启后,Redis优先使用 AOF 来恢复数据,降低丢失数据的量

官方推荐两个都启用。

  • 如果对数据不敏感,可以选单独用RDB。
  • 不建议单独用 AOF,因为可能会出现Bug。
  • 如果只是做纯内存缓存,可以都不用。

image-20210908114833273

  • RDB持久化方式能够在指定的时间间隔能对你的数据进行快照存储
  • AOF持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF命令以redis协议追加保存每次写的操作到文件末尾。
  • Redis还能对AOF文件进行后台重写,使得AOF文件的体积不至于过大
  • 只做缓存:如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
  • 同时开启两种持久化方式
    • 在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
    • RDB的数据不实时,同时使用两者时服务器重启也只会找AOF文件。那要不要只使用AOF呢?
      • 建议不要,因为RDB更适合用于备份数据库(AOF在不断变化不好备份), 快速重启,而且不会有AOF可能潜在的bug,留着作为一个万一的手段。
  • 性能建议
    • 因为RDB文件只用作后备用途,建议只在Slave上持久化RDB文件,而且只要15分钟备份一次就够了,只保留save 900 1这条规则。
    • 如果使用AOF,好处是在最恶劣情况下也只会丢失不超过两秒数据,启动脚本较简单只load自己的AOF文件就可以了。
    • 代价:
      1. 一是带来了持续的IO
      2. 二是AOF rewrite的最后将rewrite过程中产生的新数据写到新文件造成的阻塞几乎是不可避免的。
    • 只要硬盘许可,应该尽量减少AOF rewrite的频率,AOF重写的基础大小默认值64M太小了,可以设到5G以上。
    • 默认超过原大小100%大小时重写可以改到适当的数值。

5、AOF+RDB混合[推荐]

1、介绍

看了上面的RDB和AOF的介绍后,我们可以发现:

  • 使用RDB持久化会有数据丢失的风险,但是恢复速度快,
  • 而使用AOF持久化可以保证数据完整性,但恢复数据的时候会很慢。

于是从Redis4之后新增了混合AOF和RDB的模式:

  1. 先使用RDB进行快照存储,然后使用AOF持久化记录所有的写操作,
  2. 当重写策略满足或手动触发重写的时候,将最新的数据存储为新的RDB记录。
  3. 这样的话,重启服务的时候会从RDB何AOF两部分恢复数据,即保证了数据完整性,又提高了恢复的性能。

开启混合模式后:

  • 每当bgrewriteaof命令之后会在AOF文件中以RDB格式写入当前最新的数据,之后的新的写操作继续以AOF的追加形式追加写命令。
  • 当redis重启的时候,加载 aof 文件进行恢复数据:先加载 rdb 的部分再加载剩余的 aof部分。

img

2、配置

修改下面的参数即可开启AOF,RDB混合持久化:

1
aof-use-rdb-preamble yes

3、使用

开启混合持久化模式后,重写之后的aof文件里和rdb一样存储二进制的 快照数据,继续往redis中进行写操作,后续操作在aof中仍然是以命令的方式追加。

因此重写后aof文件由两部分组成:

  • 一部分是类似rdb的二进制快照
  • 另一部分是追加的命令文本:

img

6、持久化应用场景

  • Tips 1:redis用于控制数据库表主键id,为数据库表主键提供生成策略,保障数据库表的主键唯一性
  • Tips 3:redis应用于各种结构型和非结构型高热度数据访问加速
  • Tips 4:redis 应用于购物车数据存储设计
  • Tips 5:redis 应用于抢购,限购类、限量发放优惠卷、激活码等业务的数据存储设计
  • Tips 6:redis 应用于具有操作先后顺序的数据控制
  • Tips 7:redis 应用于最新消息展示
  • Tips 9:redis 应用于同类信息的关联搜索,二度关联搜索,深度关联搜索
  • Tips 12:redis 应用于基于黑名单与白名单设定的服务控制
  • Tips 13:redis 应用于计数器组合排序功能对应的排名
  • Tips 15:redis 应用于即时任务/消息队列执行管理
  • Tips 16:redis 应用于按次结算的服务控制

3、Redis 事务

1、事务简介

1、什么是事务

image-20210908022740502

Redis执行指令过程中,多条连续执行的指令被干扰,打断,插队

Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

redis事务就是一个命令执行的队列,将一系列预定义命令包装成一个整体(一个队列)。当执行时,一次性按照添加顺序依次执行,中间不会被打断或者干扰。

Redis事务的主要作用就是串联多个命令防止别的命令插队。

一个队列中,一次性、顺序性、排他性的执行一系列命令

image-20210905203742252

2、事务基本操作

1、事务的边界

redis的事务发生在 multiexec之间,能保证一系列预定义命令一次性按照添加顺序依次执行,中间不会被打断或者干扰。在执行事务当中出现错误,可以使用discard取消事务。

image-20210908023051641

2、事务的基本操作

  • 开启事务

    1
    multi
  • 作用:设定事务的开启位置,此指令执行后,后续的所有指令均加入到事务中

  • 执行事务

    1
    exec
  • 作用:设定事务的结束位置,同时执行事务。与multi成对出现,成对使用

注意:加入事务的命令暂时进入到任务队列中,并没有立即执行,只有执行exec命令才开始执行

事务定义过程中发现出了问题,怎么办?

  • 取消事务

    1
    discard
  • 作用:终止当前事务的定义,发生在multi之后,exec之前

    image-20210905204241205

3、事务的工作流程

image-20210905204405680

4、事务的注意事项

1、定义事务的过程中,命令格式输入错误怎么办?
  • 语法错误
    • 指命令书写格式有误
  • 处理结果
    • 如果定义的事务中所包含的命令存在语法错误,整体事务中所有命令均不会执行。包括那些语法正确的命令。
2、定义事务的过程中,命令执行出现错误怎么办?
  • 运行错误
    • 指命令格式正确,但是无法正确的执行。例如对list进行incr操作
  • 处理结果
    • 能够正确运行的命令会执行,运行错误的命令不会被执行

注意:已经执行完毕的命令对应的数据不会自动回滚,需要程序员自己在代码中实现回滚。

3、手动进行事务回滚
  • 记录操作过程中被影响的数据之前的状态
    • 单数据:string
    • 多数据:hash、list、set、zset
  • 设置指令恢复所有的被修改的项
    • 单数据:直接set(注意周边属性,例如时效)
    • 多数据:修改对应值或整体克隆复制

由于redis的事务没有自动进行回滚的功能,需要程序员进行手动的回滚,需要程序员自己记录事务执行前变量的值,非常的不方便。因此,redis的事务控制很少使用。

5、Redis 事务三特性

  • 单独的隔离操作
    • 事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 没有隔离级别的概念
    • 队列中的命令没有提交之前都不会实际被执行,因为事务提交前任何指令都不会被实际执行
  • 不保证原子性
    • 事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

3、锁

1、基于特定条件的事务执行——锁

Redis是的锁基于乐观锁的,乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis就是利用这种check-and-set机制实现事务的

  • 对 key 添加监视锁,在执行exec前如果key发生了变化,终止事务执行

    1
    watch key1 [key2……]

    注意:不能在事务当中进行watch操作,即在mutil当中使用,会报错。

  • 取消对所有 key 的监视

    1
    unwatch
  • 使用 setnx 设置一个公共锁

    1
    setnx lock-key value

    利用setnx命令的返回值特征,有值则返回设置失败,无值则返回设置成功

    • 对于返回设置成功的,拥有控制权,进行下一步的具体业务操作
    • 对于返回设置失败的,不具有控制权,排队或等待 操作完毕通过del操作释放锁

    注意:上述解决方案是一种设计概念,依赖规范保障,具有风险性

  • 使用 expire 为锁key添加时间限定,到时不释放,放弃锁

    1
    2
    expire lock-key second
    pexpire lock-key milliseconds

    由于操作通常都是微秒或毫秒级,因此该锁定时间不宜设置过大。具体时间需要业务测试后确认。

    • 例如:持有锁的操作最长执行时间127ms,最短执行时间7ms。
    • 测试百万次最长执行时间对应命令的最大耗时,测试百万次网络延迟平均耗时
    • 锁时间设定推荐:最大耗时 * 120% + 平均网络延迟 * 110%
    • 如果业务最大耗时<<网络平均延迟,通常为2个数量级,取其中单个耗时较长即可

2、锁的应用场景

  • **Tips 18 **
    • redis 应用基于状态控制的批量任务执行
      • 天猫双11热卖过程中,对已经售罄的货物追加补货,4个业务员都有权限进行补货。补货的操作可能是一系列的操作,牵扯到多个连续操作,如何保障不会重复操作?
  • **Tips 19 **
    • redis 应用基于分布式锁对应的场景控制
      • 天猫双11热卖过程中,对已经售罄的货物追加补货,且补货完成。客户购买热情高涨,3秒内将所有商品购买完毕。本次补货已经将库存全部清空,如何避免最后一件商品不被多人同时购买?【超卖问题】

4、redssion

众所周知,Redis 其实并没有对 Java 提供原生支持。作为 Java 开发人员,我们若想在程序中集成 Redis,必须使用 Redis 的第三方库。而 Redisson 就是用于在 Java 程序中操作 Redis 的库,它使得我们可以在程序中轻松地使用 Redis。Redisson 在 java.util 中常用接口的基础上,为我们提供了一系列具有分布式特性的工具类。

1、如何安装 Redisson

安装 Redisson 最便捷的方法是使用 Maven

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.11.4</version>
</dependency>

你可以通过搜索 Maven 中央仓库 mvnrepository 来找到 Redisson 的各种版本。

2、如何编译运行 Redisson

安装 Redisson 后,只需使用 Java 编译器即可编译和运行 Redisson 代码:

1
2
3
javac RedissonExamples.java

java RedissonExamples

3、对Redisson API的相关使用

对Redisson API的相关使用,可以参考以下博客:


4、Redis 删除策略

1、过期数据

1、Redis中的数据特征

Redis是一种内存级数据库,所有数据均存放在内存中,内存中的数据可以通过TTL指令获取其状态:

  • XX :具有时效性的数据
  • -1 :永久有效的数据
  • -2 :已经过期的数据 或 被删除的数据 或 未定义的数据

过期的数据真的删除了吗?

并不是,过期数据的删除其实主要是由redis的删除策略进行控制。但一般来说,过期的数据并不是马上删除的,还是存放在redis的内存当中,只是根据redis的删除策略对过期的数据在不同情况下进行真正删除。

2、数据删除策略

redis有三种数据删除策略,分别是:

  1. 定时删除
  2. 惰性删除
  3. 定期删除

2、数据删除策略

官网:https://redis.io/commands/expire#expire-accuracy

1、时效性数据的存储结构

image-20210905210951460

2、数据删除策略的目标

在内存占用与CPU占用之间寻找一种平衡,顾此失彼都会造成整体redis性能的下降,甚至引发服务器宕机或内存泄露。

3、数据删除策略——定时删除

  • 创建一个定时器,当key设置有过期时间,且过期时间到达时,由定时器任务立即执行对键的删除操作
  • 优点:节约内存,到时就删除,快速释放掉不必要的内存占用
  • 缺点:CPU压力很大,无论CPU此时负载量多高,均占用CPU,会影响redis服务器响应时间和指令吞吐量
  • 总结:用处理器性能换取存储空间(拿时间换空间)

image-20210905211328753

4、数据删除策略——惰性删除

数据到达过期时间,不做处理。等下次访问该数据时

  • 如果未过期,返回数据
  • 发现已过期,删除,返回不存在
  • 优点:节约CPU性能,发现必须删除的时候才删除
  • 缺点:内存压力很大,出现长期占用内存的数据
  • 总结:用存储空间换取处理器性能(拿空间换时间)

image-20210905211533959

5、数据删除策略——定期删除

两种方案都走极端,有没有折中方案?

  • Redis启动服务器初始化时,读取配置server.hz的值,默认为10
    • 每秒钟执行server.hz次serverCron() –》 activeExpireCycle() – 》activeExpireCycle()
    • *activeExpireCycle()**对每个expires[]逐一进行检测,每次执行250ms/server.hz
    • 对某个expires[*]检测时,随机挑选W个key检测
      • 如果key超时,删除key
      • 如果一轮中删除的key的数量 > W * 25%,循环该过程
      • 如果一轮中删除的key的数量 ≤ W * 25%,检查下一个expires[*],0-15循环
      • W取值 = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP属性值
    • 参数current_db用于记录activeExpireCycle() 进入哪个expires[*] 执行
    • 如果**activeExpireCycle()**执行时间到期,下次从current_db继续向下执行databasesCron

image-20210905212056307

  • 周期性轮询redis库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度
  • 特点1:CPU性能占用设置有峰值,检测频度可自定义设置
  • 特点2:内存压力不是很大,长期占用内存的冷数据会被持续清理
  • 总结:周期性抽查存储空间(随机抽查,重点抽查

6、删除策略比对

定时删除 节约内存,无占用 不分时段占用CPU资源,频度高 拿时间换空间
惰性删除 内存占用严重 延时执行,CPU利用率高 拿空间换时间
定期删除 内存定期随机清理 每秒花费固定的CPU资源维护内存 随机抽查,重点抽查

redis会使用的两个删除策略:

  • 惰性删除
  • 定期删除

3、逐出算法

1、新数据进入检测

当新数据进入redis时,如果内存不足怎么办?

  • Redis使用内存存储数据,在执行每一个命令前,会调用freeMemoryIfNeeded()检测内存是否充足。如果内存不满足新加入数据的最低存储要求,redis要临时删除一些数据为当前指令清理存储空间清理数据的策略称为逐出算法

  • 注意:逐出数据的过程不是100%能够清理出足够的可使用的内存空间,如果不成功则反复执行。当对所有数据尝试完毕后,如果不能达到内存清理的要求,将出现错误信息。

    1
    (error) OOM command not allowed when used memory >'maxmemory'

2、影响数据逐出的相关配置

  • 最大可使用内存

    1
    maxmemory

    占用物理内存的比例,默认值为0,表示不限制生产环境中根据需求设定,通常设置在50%以上

  • 查看当前最大可使用内存

    1
    2
    3
    127.0.0.1:6379> config get maxmemory
    1) "maxmemory"
    2) "0"

    默认值为0

  • 设置当前最大可使用内存

    1
    2
    3
    4
    5
    127.0.0.1:6379> config set maxmemory 1GB
    OK
    127.0.0.1:6379> config get maxmemory
    1) "maxmemory"
    2) "1073741824"
  • 也可以通过配置文件对最大可使用内存进行配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 配置文件
    maxmemory <bytes>
    # 下面的写法均合法:
    maxmemory 1024000
    maxmemory 1GB
    maxmemory 1G
    maxmemory 1024KB
    maxmemory 1024K
    maxmemory 1024MB

    img

    maxmemory参数默认值为0。因32位系统支持的最大内存为4GB,所以在32位系统上Redis的默认最大内存限制为3GB;在64位系统上默认Redis最大内存即为物理机的可用内存;

  • 每次选取待删除数据的个数

    1
    maxmemory-samples

    选取数据时并不会全库扫描,导致严重的性能消耗,降低读写性能。因此采用随机获取数据的方式作为待检测删除数据

  • 删除策略

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 配置文件
    maxmemory-policy noeviction

    #命令行
    127.0.0.1:6379> config get maxmemory-policy
    1) "maxmemory-policy"
    2) "noeviction"
    127.0.0.1:6379> config set maxmemory-policy allkeys-random
    OK
    127.0.0.1:6379> config get maxmemory-policy
    1) "maxmemory-policy"
    2) "allkeys-random"

    达到最大内存后的,对被挑选出来的数据进行删除的策略

redis有8种删除策略

  • 检测易失数据(可能会过期的数据集server.db[i].expires)

    • volatile-lru:挑选最近最少使用的数据淘汰(早期redis一般的默认策略

    • volatile-lfu:挑选最近使用次数最少的数据淘汰

      image-20210905213148222

    • volatile-ttl:挑选将要过期的数据淘汰

    • volatile-random任意选择数据淘汰

  • 检测全库数据(所有数据集server.db[i].dict )

    • allkeys-lru:挑选最近最少使用的数据淘汰
    • allkeys-lfu:挑选最近使用次数最少的数据淘汰
    • allkeys-random:任意选择数据淘汰
  • 放弃数据驱逐

    • no-enviction(驱逐)禁止驱逐数据(redis4.0中默认策略),会引发错误OOM(Out Of Memory)。当内存达到设置的最大值时,所有申请内存的操作都会报错(如set,lpush等),只读操作如get命令可以正常执行

在配置启动的文件中配置:

1
maxmemory-policy volatile-lru

3、LRU算法

1、介绍

LRU(Least Recently Used)表示最近最少使用,该算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

2、底层实现

LRU算法的常见实现方式为链表:新数据放在链表头部 ,链表中的数据被访问就移动到链头,链表满的时候从链表尾部移出数据。

img

而在Redis中使用的是近似LRU算法,为什么说是近似呢?Redis中是随机采样5个(可以修改参数maxmemory-samples配置)key,然后从中选择访问时间最早的key进行淘汰,因此当采样key的数量与Redis库中key的数量越接近,淘汰的规则就越接近LRU算法。但官方推荐5个就足够了,最多不超过10个,越大就越消耗CPU的资源。

但在LRU算法下,如果一个热点数据最近很少访问,而非热点数据近期访问了,就会误把热点数据淘汰而留下了非热点数据,因此在Redis4.x中新增了LFU算法。

LRU算法下,Redis会为每个key新增一个3字节的内存空间用于存储key的访问时间

4、LFU算法

1、介绍

LFU(Least Frequently Used)表示最不经常使用,它是根据数据的历史访问频率来淘汰数据,其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

LFU算法反映了一个key的热度情况,不会因LRU算法的偶尔一次被访问被误认为是热点数据。

2、底层实现

LFU算法的常见实现方式为链表:新数据放在链表尾部 ,链表中的数据按照被访问次数降序排列,访问次数相同的按最近访问时间降序排列,链表满的时候从链表尾部移出数据。

img

5、数据逐出策略配置依据

使用INFO命令输出监控信息,查询缓存 hit 和 miss 的次数,根据业务需求调优Redis配置

image-20210905213301214


5、Redis 核心配置

服务器基础配置

1、服务器端设定

  • 设置服务器以守护进程的方式运行

    1
    daemonize yes|no
  • 绑定主机地址

    1
    bind 127.0.0.1

    没有配置bind的话,默认使用的是127.0.0.1,localhost也是可以。

    但是一旦配置了bind,就必须使用配置的IP进行访问,localhost也不行了

  • 设置服务器端口号

    1
    port 6379
  • 设置数据库数量

    1
    databases 16

2、日志配置

  • 设置服务器以指定日志记录级别

    1
    loglevel debug|verbose|notice|warning
  • 日志记录文件名

    1
    logfile 端口号.log

注意:日志级别==开发期设置为verbose==即可,==生产环境中配置为notice==,简化日志输出量,降低写日志IO的频度

3、客户端配置

  • 设置同一时间最大客户端连接数,默认无限制。当客户端连接到达上限,Redis会关闭新的连接

    1
    maxclients 0
  • 客户端闲置等待最大时长,达到最大值后关闭连接。如需关闭该功能,设置为 0,单位是:秒/s

    1
    timeout 300

4、多服务器快捷配置

  • 导入并加载指定配置文件信息,用于快速创建redis公共配置较多的redis实例配置文件,便于维护

    1
    include /path/server-端口号.conf

即:如果配置文件过多,可以将一些公共部分抽取出来作为一个公共的配置文件,在其他的配置文件当中,使用以上配置将公共配置文件进行导入


6、高级数据类型

1、Bitmaps

1、存储需求

image-20210905214141050

计算机所能操作的最小单位是:Byte字节,1Byte = 8bit

而使用Bitmaps能让我们去操作bit,用于状态的判断(即:非真既假的情况)

合理地使用操作位能够有效地提高内存使用率和开发效率

Redis提供了Bitmaps这个“数据类型”可以实现对位的操作:

  1. Bitmaps本身不是一种数据类型, 实际上它就是字符串(key-value) , 但是它可以对字符串的位进行操作
  2. Bitmaps单独提供了一套命令, 所以在Redis中使用Bitmaps和使用字符串的方法不太相同。 可以把Bitmaps想象成一个以位为单位的数组, 数组的每个单元只能存储0和1, 数组的下标在Bitmaps中叫做偏移量。

image-20210908021224660

2、Bitmaps类型的基础操作

  • 获取指定key对应偏移量上的bit值

    1
    2
    # a:0110 0011 --》 getbit a 6 <-> 1
    getbit key offset
  • 设置指定key对应偏移量上的bit值,value只能是1或0

    1
    2
    # a:0110 0011 --》 getbit a 6 0 <-> a:0100 0011
    setbit key offset value
  • 对指定key按位进行交、并、非、异或操作,并将结果保存到destKey中

    1
    2
    3
    # a:01010011 b:11011001
    # bitop or c a b <-> c:11011011
    bitop op destKey key1 [key2...]
    • and:交
    • or:并
    • not:非
    • xor:异或
  • 统计指定key中1的数量

    1
    2
    # bitcount c <-> 6
    bitcount key [start end]

3、Bitmaps与set对比

假设网站有1亿用户, 每天独立访问的用户有5千万, 如果每天用集合类型和Bitmaps分别存储活跃用户可以得到表

set和Bitmaps存储一天活跃用户对比:

数据类型 每个用户id占用空间 需要存储的用户量 全部内存量
集合类型 64位 50000000 64位*50000000 = 400MB
Bitmap 1位 100000000 1位*100000000 = 12.5MB

很明显, 这种情况下使用Bitmaps能节省很多的内存空间, 尤其是随着时间推移节省的内存还是非常可观的

set和Bitmaps存储独立用户空间对比:

数据类型 一天 一个月 一年
集合类型 400MB 12GB 144GB
Bitmaps 12.5MB 375MB 4.5GB

但Bitmaps并不是万金油, 假如该网站每天的独立访问用户很少, 例如只有10万(大量的僵尸用户) , 那么两者的对比如下表所示, 很显然, 这时候使用Bitmaps就不太合适了, 因为基本上大部分位都是0。

set和Bitmaps存储一天活跃用户对比(独立用户比较少)

数据类型 每个userid占用空间 需要存储的用户量 全部内存量
集合类型 64位 100000 64位*100000 = 800KB
Bitmaps 1位 100000000 1位*100000000 = 12.5MB

4、Bitmaps的应用场景

  • **Tips 21 **
    • redis 应用于信息状态统计
      • 电影网站
        • 统计每天某一部电影是否被点播
        • 统计每天有多少部电影被点播
        • 统计每周/月/年有多少部电影被点播
        • 统计年度哪部电影没有被点播

2、HyperLogLog

1、基数

  • 基数是数据集去重后元素个数
  • HyperLogLog 是用来做基数统计的,运用了LogLog的算法
  • HyperLogLog 的优点是:在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。
  • 在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
  • 但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。
  • 示例:
    • {1, 3, 5, 7, 5, 7, 8}
      • 基数集: {1, 3, 5 ,7, 8}
      • 基数:5
    • {1, 1, 1, 1, 1, 7, 1}
      • 基数集: {1,7}
      • 基数:2

2、LogLog算法(跳过)

image-20210905215436319

3、HyperLogLog类型的基本操作

  • 添加数据

    1
    pfadd key element [element ...]
  • 统计数据

    1
    pfcount key [key ...]
  • 合并数据

    1
    pfmerge destkey sourcekey [sourcekey...]

4、HyperLogLog的相关说明

  • 用于进行基数统计,不是集合,不保存数据,只记录数量而不是具体数据
  • 核心是基数估算算法,最终数值存在一定误差
  • 误差范围:基数估计的结果是一个带有 0.81% 标准错误的近似值
  • 耗空间极小,每个hyperloglog key占用了12K的内存用于标记基数
  • pfadd命令不是一次性分配12K内存使用,会随着基数的增加内存逐渐增大
  • Pfmerge命令合并后占用的存储空间为12K,无论合并之前数据量多少

5、HyperLogLog的应用场景

  • **Tips 22 **
    • redis 应用于独立信息统计
      • 统计独立UV
        • 原始方案:set
          • 存储每个用户的id(字符串)
        • 改进方案:Bitmaps
          • 存储每个用户状态(bit)
        • 全新的方案:Hyperloglog

3、GEO

1、GEO简介

  • Redis 3.2 中增加了对GEO类型的支持。
  • GEO,Geographic,地理信息的缩写。
  • 该类型,就是元素的2维坐标,在地图上就是经纬度。
  • redis基于该类型,提供了经纬度设置,查询,范围查询,距离查询,经纬度Hash等常见操作

GEO用于计算两地经纬度的距离

image-20210905220109563

2、GEO类型的基本操作

  • 添加坐标点

    1
    2
    # geoadd + 容器key + 经度 + 维度 + 名称
    geoadd key longitude latitude member [longitude latitude member ...]
  • 获取坐标点

    1
    geopos key member [member ...]

    它会做一些经纬度的度分秒的转换

  • 计算坐标点距离,单位:米/m

    1
    2
    # unit为单位,默认为米/m,可以设置成千米/km
    geodist key member1 member2 [unit]

    注意:geo计算的是水平位置的距离

  • 根据坐标求范围内的数据(不定点,如移动当中的位置)

    1
    georadius key longitude latitude radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]
    • [withcoord]:结果跟随坐标
    • [withdist]:结果跟随距离
    • [withhash]:结果跟坐标的hash值
    • [count count]:结果取一定的范围,从count到count

    另外,还能再加上两个参数:

    • asc/desc:按照距离进行升序/降序
  • 根据点求范围内的数据(定点)

    1
    georadiusbymember key member radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]

    一些操作与上面一样

  • 获取指定点对应的坐标hash值

    1
    geohash key member [member ...]

3、GEO的应用场景

  • **Tips 23 **
    • redis 应用于地理位置计算
      • 火热的生活服务类软件(当中显示的距离)
        • 微信 / 陌陌
        • 美团 / 饿了么
        • 携程 / 马蜂窝
        • 高德 / 百度

7、主从复制

1、主从复制简介

1、互联网“三高”架构

  • 高并发
  • 高性能
  • 高可用

对于高可用:

假设在一年当中,服务器的宕机有:

  • 在一月,服务器宕机4小时27分15秒
  • 在四月,服务器宕机11分36秒
  • 在十月,服务器宕机2分16秒

那么在这一年当中服务器的可用性为:

image-20210907010831610

2、“Redis”是否高可用

单机redis的风险与问题:

  • 问题1:机器故障
    • 现象:硬盘故障、系统崩溃
    • 本质:数据丢失,很可能对业务造成灾难性打击
    • 结论:基本上会放弃使用redis.
  • 问题2:容量瓶颈
    • 现象:内存不足,从16G升级到64G,从64G升级到128G,无限升级内存
    • 本质:穷,硬件条件跟不上
    • 结论:放弃使用redis
  • 结论:
    • 为了避免单点Redis服务器故障,准备多台服务器,互相连通
    • 将数据复制多个副本保存在不同的服务器上,连接在一起,并保证数据是同步的
    • 即使有其中一台服务器宕机,其他服务器依然可以继续提供服务,实现Redis的高可用,同时实现数据冗余备份。

3、多台服务器连接方案

  • 提供数据方:master
    • 主服务器,主节点,主库
    • 主客户端
  • 接收数据方:slave
    • 从服务器,从节点,从库
    • 从客户端
  • 需要解决的问题: 数据同步
  • 核心工作: master的数据复制到slave中

image-20210907011143290

4、主从复制

主从复制即将master中的数据即时、有效的复制到slave中

特征:一个master可以拥有多个slave,一个slave只对应一个master

职责:(读写分离)

  • master:
    • 写数据
    • 执行写操作时,将出现变化的数据自动同步到slave
    • 读数据(可忽略)
  • slave:
    • 读数据
    • 写数据(禁止)

5、高可用集群

1、在一个slave结点宕机之后,并不影响redis可用性

image-20210907011448753

2、在一个master结点宕机之后,可以有一个slave升级为master继续使用,并不影响redis可用性

image-20210907011537226

3、在一个master结点压力过大,可以将一部分工作交给一个slave结点去做,让这个slave作为master去管理它的从结点(master与slave只是相对来说的)

image-20210907011704140

4、如果一个master来接收外界数据不太安全的话,也可以将多个master做成集群

image-20210907011806247

6、主从复制的作用

  • 读写分离master写、slave读,提高服务器的读写负载能力
  • 负载均衡:基于主从结构,配合读写分离,由slave分担master负载,并根据需求的变化,改变slave的数量,通过多个从节点分担数据读取负载,大大提高Redis服务器并发量与数据吞吐量
  • 故障恢复当master出现问题时,由slave提供服务,实现快速的故障恢复
  • 数据冗余实现数据热备份,是持久化之外的一种数据冗余方式
  • 高可用基石:基于主从复制,构建哨兵模式与集群,实现Redis的高可用方案

2、主从复制工作流程

1、总述

  • 主从复制过程大体可以分为3个阶段
    1. 建立连接阶段(即准备阶段)
    2. 数据同步阶段
    3. 命令传播阶段

image-20210907012115436

2、阶段一:建立连接阶段

  • 建立slave到master的连接,使master能够识别slave,并保存slave端口号
1、建立连接阶段工作流程
  1. 步骤1:设置master的地址和端口,保存master信息
  2. 步骤2:建立socket连接
  3. 步骤3:发送ping命令(定时器任务)
  4. 步骤4:身份验证
  5. 步骤5:发送slave端口信息
  6. 至此,主从连接成功!

image-20210907012520884

状态:

  • slave:
    • 保存master的地址与端口
  • master:
    • 保存slave的端口
  • 总体:
    • 之间创建了连接的socket
2、主从连接(slave连接master)
  • 方式一:客户端发送命令

    1
    slaveof <masterip> <masterport>
  • 方式二:启动服务器参数

    1
    redis-server -slaveof <masterip> <masterport>
  • 方式三:服务器配置(常用)

    1
    slaveof <masterip> <masterport>

slave系统信息:

  • master_link_down_since_seconds:主从断开的持续时间(以秒为单位) .
  • masterhost
  • masterport

master系统信息:

  • slave_listening_port(多个)
3、主从断开连接
  • 客户端发送命令

    1
    slaveof no one

说明: slave断开连接后,不会删除已有数据,只是不再接受master发送的数据

4、授权访问
  • master客户端发送命令设置密码

    1
    requirepass <password>
  • master配置文件设置密码

    1
    2
    3
    config set requirepass <password>

    config get requirepass
  • slave客户端发送命令设置密码

    1
    auth <password>
  • slave配置文件设置密码

    1
    masterauth <password>
  • slave启动服务器设置密码

    1
    redis-server –a <password>

由于redis在主从进行数据交流的是在内网上进行的,所以一般不设置密码也没有关系。

2、阶段二:数据同步阶段工作流程

  • 在slave初次连接master后,复制master中的所有数据到slave
  • 将slave的数据库状态更新成master当前的数据库状态
1、数据同步阶段工作流程
  1. 步骤1:请求同步数据
  2. 步骤2:创建RDB同步数据(全量复制)
  3. 步骤3:恢复RDB同步数据
  4. 步骤4:请求部分同步数据(部分复制)(AOF同步)
  5. 步骤5:恢复部分同步数据
  6. 至此,数据同步工作完成!

image-20210907013653595

状态:

  • slave: 具有master端全部数据,包含RDB过程接收的数据
  • master: 保存slave当前数据同步的位置
  • 总体: 完成了数据克隆
2、数据同步阶段master说明
  1. 如果master数据量巨大,数据同步阶段应避开流量高峰期,避免造成master阻塞,影响业务正常执行

    • 可以选择在半夜的3、4点钟进行数据同步
    • 注意:这是全量复制的时候,也就是你新增从属服务器要同步的时候,一般之后的实时同步都是部分复制 量很少的,速度很快。
  2. 复制缓冲区大小设定不合理,会导致数据溢出。如进行全量复制周期太长,进行部分复制时发现数据已经存在丢失的情况,必须进行第二次全量复制,致使slave陷入死循环状态。

    image-20210907014215104

    1
    repl-backlog-size 1mb

    通过设置复制缓冲区大小就能解决这个问题

  3. master单机内存占用主机内存的比例不应过大,建议使用50%-70%的内存,留下30%-50%的内存用于执行bgsave命令和创建复制缓冲区

3、数据同步阶段slave说明
  1. 为避免slave进行全量复制、部分复制时服务器响应阻塞或数据不同步,建议关闭此期间的对外服务

    1
    2
    3
    4
    5
    # 开启只读服务应该是这个指令:
    slave-read-only yes

    # 当主服务器挂掉时是否提供过期数据
    slave-serve-stale-data yes|no
  2. 数据同步阶段,master发送给slave信息可以理解master是slave的一个客户端,主动向slave发送命令

  3. 多个slave同时对master请求数据同步,master发送的RDB文件增多,会对带宽造成巨大冲击,如果master带宽不足,因此数据同步需要根据业务需求,适量错峰

  4. slave过多时,建议调整拓扑结构,由一主多从结构变为树状结构,中间的节点既是master,也是slave。

    • 注意使用树状结构时,由于层级深度,导致深度越高的slave与最顶层master间数据同步延迟较大,数据一致性变差,应谨慎选择

3、阶段三:命令传播阶段

  • 当master数据库状态被修改后,导致主从服务器数据库状态不一致,此时需要让主从数据同步到一致的状态,同步的动作称为命令传播
  • master将接收到的数据变更命令发送给slave,slave接收命令后执行命令
1、命令传播阶段的部分复制
  • 命令传播阶段出现了断网现象
    • 网络闪断闪连:忽略
    • 短时间网络中断:部分复制
    • 长时间网络中断:全量复制
  • 部分复制的三个核心要素
    • 服务器的运行 id(run id)
    • 主服务器的复制积压缓冲区
    • 主从服务器的复制偏移量
2、服务器运行ID(runid)
  • 概念:服务器运行ID是每一台服务器每次运行的身份识别码,一台服务器多次运行可以生成多个运行id
  • 组成:运行id由40位字符组成,是一个随机的十六进制字符
    • 例如:fdc9ff13b9bbaab28db42b3d50f852bb5e3fcdce
  • 作用:运行id被用于在服务器间进行传输,识别身份
    • 如果想两次操作均对同一台服务器进行,必须每次操作携带对应的运行id,用于对方识别
  • 实现方式:运行id在每台服务器启动时自动生成的,master在首次连接slave时,会将自己的运行ID发送给slave,slave保存此ID,通过info Server命令,可以查看节点的runid
3、复制缓冲区
  • 概念:复制缓冲区,又名复制积压缓冲区,是一个先进先出(FIFO)的队列,用于存储服务器执行过的命令,每次传播命令,master都会将传播的命令记录下来,并存储在复制缓冲区

    image-20210907015127642

  • 由来:每台服务器启动时,如果开启有AOF或被连接成为master节点,即创建复制缓冲区

  • 作用:用于保存master收到的所有指令仅影响数据变更的指令,例如set,select)

  • 数据来源:当master接收到主客户端的指令时,除了将指令执行,会将该指令存储到缓冲区中

  • 组成:

    • 偏移量
    • 字节值
  • 工作原理

    • 通过offset区分不同的slave当前数据传播的差异
    • master记录已发送的信息对应的offset
    • slave记录已接收的信息对应的offset

image-20210907015201028

4、主从服务器复制偏移量(offset)
  • 概念:一个数字,描述复制缓冲区中的指令字节位置
  • 分类:
    • master复制偏移量:记录发送给所有slave的指令字节对应的位置(多个)
    • slave复制偏移量:记录slave接收master发送过来的指令字节对应的位置(一个)
    • 之后通过master与slave之间的offset对比,就知道当前的slave有多少数据没有复制过去,相等表示当前slave已经有master的全部数据
  • 数据来源: master端:发送一次记录一次 slave端:接收一次记录一次
  • 作用:同步信息,比对master与slave的差异,当slave断线后,恢复数据使用

4、数据同步+命令传播阶段工作流程

image-20210907015608735

5、心跳机制

  • 进入命令传播阶段候,master与slave间需要进行信息交换,使用心跳机制进行维护,实现双方连接保持在线
  • master心跳:
    • 指令:PING
    • 周期:由repl-ping-slave-period决定,默认10秒(由于一个master会有多个slave,所以周期相对于slave来说会比较长)
    • 作用:判断slave是否在线
    • 查询:INFO replication 获取slave最后一次连接时间间隔lag项维持在0或1视为正常
      • 关于lag:如果在网络上的话,较为稳定,出现0的次数会比较少
  • slave心跳任务
    • 指令:REPLCONF ACK {offset}
    • 周期:1秒(由于一个slave会对应一个master,所以周期会比较短)
    • 作用1:汇报slave自己的复制偏移量,获取最新的数据变更指令
    • 作用2:判断master是否在线

6、心跳阶段注意事项

  • 当slave多数掉线,或延迟过高时,master为保障数据稳定性,将拒绝所有信息同步操作

    1
    2
    3
    min-slaves-to-write 2

    min-slaves-max-lag 10

    slave数量少于2个,或者所有slave的延迟都大于等于10秒时,强制关闭master写功能,停止数据同步

  • slave数量由slave发送REPLCONF ACK命令做确认

  • slave延迟由slave发送REPLCONF ACK命令做确认

7、主从复制工作流程(完整)

image-20210907020216293

3、主从复制常见问题

1、频繁的全量复制(1)

伴随着系统的运行,master的数据量会越来越大,一旦master重启,runid将发生变化,会导致全部slave的全量复制操作

内部优化调整方案:

  1. master内部创建master_replid变量,使用runid相同的策略生成,长度41位,并发送给所有slave
  2. 在master关闭时执行命令 shutdown save进行RDB持久化,将runid与offset保存到RDB文件中
    • repl-id repl-offset
    • 通过redis-check-rdb命令可以查看该信息
  3. master重启后加载RDB文件,恢复数据
    • 重启后,将RDB文件中保存的repl-id与repl-offset加载到内存中
      • master_repl_id = repl
      • master_repl_offset = repl-offset
    • 通过info命令可以查看该信息
  4. 作用:本机保存上次runid,重启后恢复该值,使所有slave认为还是之前的master

2、频繁的全量复制(2)

  • 问题现象:网络环境不佳,出现网络中断,slave不提供服务

  • 问题原因:复制缓冲区过小,断网后slave的offset越界,触发全量复制

  • 最终结果:slave反复进行全量复制,对外不提供服务

  • 解决方案:修改复制缓冲区大小

    1
    repl-backlog-size
  • 建议设置如下:

    1. 测算从master到slave的重连平均时长second
    2. 获取master平均每秒产生写命令数据总量write_size_per_second
    3. 最优复制缓冲区空间 = 2 * second * write_size_per_second

3、频繁的网络中断(1)

  • 问题现象:master的CPU占用过高 或 slave频繁断开连接

  • 问题原因:

    • slave每1秒发送REPLCONF ACK命令到master
    • 当slave接到了慢查询时(keys * ,hgetall等),会大量占用CPU性能
    • master每1秒调用复制定时函数replicationCron(),比对slave发现长时间没有进行响应
  • 最终结果:master各种资源(输出缓冲区、带宽、连接等)被严重占用

  • 解决方案:通过设置合理的超时时间,确认是否释放slave

    1
    repl-timeout

    该参数定义了超时时间的阈值默认60秒),超过该值,释放slave

4、频繁的网络中断(2)

  • 问题现象:slave与master连接断开

  • 问题原因:

    • master发送ping指令频度较低
    • master设定超时时间较短
    • ping指令在网络中存在丢包
  • 解决方案:提高ping指令发送的频度

    1
    repl-ping-slave-period

    超时时间repl-time的时间至少是ping指令频度的5到10倍,否则slave很容易判定超时

5、数据不一致

  • 问题现象:多个slave获取相同数据不同步

  • 问题原因:网络信息不同步,数据发送有延迟

  • 解决方案

    • 优化主从间的网络环境,通常放置在同一个机房部署

      • 如使用阿里云等云服务器时要注意此现象,因为对于云服务器来说,在同一城市服务器不一定同一个机房
    • 监控主从节点延迟(通过offset)判断,如果slave延迟过大,暂时屏蔽程序对该slave的数据访问

      1
      slave-serve-stale-data yes|no

      开启后仅响应info、slaveof等少数命令(慎用,除非对数据一致性要求很高)

      注意:

      • 开启后并不是说关掉这台服务器,而是关掉对这台服务器数据的访问,一般在==调试==当中使用
      • 另外,数据不同步在分布式的数据层级上面是属于非常正常的一件事,主要看你的业务需求对该数据的一致性是否有严格的要求。
      • 如果对某些数据的一致性特别严格的话,建议把这一部分数据单独存放,找一台机器又读又写,数据量不是特别大。
      • 对那些数据特别不是特别高的分开放。
      • 这样可以在一定程度上解决问题

8、哨兵模式

1、哨兵简介

1、主机“宕机”

image-20210907021639156

当主机宕机了怎么办?

  • 关闭master和所有slave
  • 找一个slave作为master
  • 修改其他slave的配置,连接新的主
  • 启动新的master与slave
  • 全量复制 * N + 部分复制 * N

相关问题:

  • 关闭期间的数据服务谁来承接?
  • 找一个主?怎么找法?
  • 修改配置后,原始的主恢复了怎么办?

问题解决:哨兵机制

image-20210907021811296

2、哨兵

哨兵(sentinel) 是一个分布式系统,用于对主从结构中的每台服务器进行==监控==,当出现故障时通过投票机制==选择==新的master并将所有slave连接到新的master。

哨兵(sentinel) 也是一个redis服务器集群,只是配置文件的与平常的redis服务器有一点不同

image-20210907021944455

3、哨兵的作用

  • 监控
    • 不断的检查master和slave是否正常运行
    • master存活检测master与slave运行情况检测
  • 通知(提醒)
    • 当被监控的服务器出现问题时,向其他(哨兵间,客户端)发送通知
  • 自动故障转移
    • 断开master与slave连接,选取一个slave作为master,将其他slave连接到新的master,并告知客户端新的服务器地址
  • 注意:
    • 哨兵也是一台redis服务器,只是不提供数据服务
    • 通常哨兵配置数量为==单数==
      • 防止哨兵在竞选中打平的这种尴尬局面

2、启用哨兵模式

1、配置哨兵

  • 配置一拖二的主从结构——1个master对应2个slave

  • 配置三个哨兵(配置相同,端口不同) 参看sentinel.conf

  • 启动哨兵

    1
    redis-sentinel sentinel-端口号.conf

    启动哨兵的时候,哨兵相应的配置文件也会改变。

    添加进去哨兵的相关内容:其他哨兵的主机名、IP、端口、runid等等

2、配置哨兵

查看redis原配置的一个好命令:如果你不想看配置文件当中的注释,使用如下命令:

1
cat sentinel.conf | grep -v "#" | grep -v "^$" 
配置项 范例 说明
sentinel auth-pass <自定义服务器名称> <password> sentinel auth-pass mymaster itcast 连接服务器口令
sentinel monitor <自定义服务名称><主机地址><端口><主从服务器总量> sentinel monitor mymaster 192.168.194.131 6381 1 设置哨兵监听的主服务器信息,最后的参数决定了最终参与选举的服务器数量(-1)
sentinel down-after-milliseconds<自定义服务名称><毫秒数(整数)> sentinel down-after-milliseconds mymaster 3000 指定哨兵在监控Redis服务时,判定服务器挂掉的时间周期,默认30秒(30000),也是主从切换的启动条件之一
sentinel parallel-syncs<服务名称><服务器数(整数)> sentinel parallel-syncs mymaster 1 指定每次同时进行主从的slave数量,数值越大,要求网络资源越高,要求越小,同步时间越长
sentinel failover-timeout<服务名称><毫秒数(整数)> sentinel failover-timeout mymaster 9000 指定出现故障后,故障切换的最大超时时间,超过该值,认定切换失败,默认3分钟。即在进行同步的时候,如果同步时间过慢也算失败
sentinel notification-script<服务名称><脚本路径> 服务器无法正常联通时,设定的执行脚本,通常调试使用。

注意:

  • 关于<自定义服务名称>,上面设定的是mymaster,设定之后在配置文件当中的各项配置中就不要修改
  • 关于sentinel monitor 的最后一个参数<主从服务器总量>,上面设定这个值为x(这里的x = 1)
    • x 的意义:如果有x个哨兵认为当前master宕机了,那么就认定该master已经宕机了——这是判断master是否宕机的一个标准
    • 这个值通常设定为哨兵的数量 的一半+1——这里也是为什么设定哨兵的数量最好是单数(防止出现打平的局面)

3、哨兵工作原理

1、主从切换

  • 哨兵在进行主从切换过程中经历三个阶段:
    • 监控
    • 通知
    • 故障转移

2、阶段一:监控阶段

  • 用于同步各个节点的状态信息
    • 获取各个sentinel的状态(是否在线)
    • 获取master的状态
      • master属性
        • runid
        • role:master
      • 各个slave的详细信息
    • 获取所有slave的状态(根据master中的slave信息)
      • slave属性
        • runid
        • role:slave
        • master_host、master_port
        • offset
        • ……

image-20210907024141976

image-20210907024754653

3、阶段二:通知阶段

image-20210907025006909

4、阶段三:故障转移阶段

1、sentinel1发现master宕机
  1. 先将master的状态修改为flags:SRI_S_DOWN——主观下线
  2. 将这个信息在sentinel集群当中传播
    • sentinel1报出sdown,并通知其他哨兵,发送指令sentinel is-master-down-by-address-port给其余哨兵节点;
    • 哨兵的选举机制是以各哨兵节点接收到发送sentinel is-master-down-by-address-port指令的哨兵id 投票,票数最高的哨兵id会成为本次故障转移工作的哨兵Leader;
  3. 其他的sentinel前往围观,查看master是不是真的宕机
  4. 当有一半以上的sentinel认定master已经宕机,则将master的状态修改为flags:SRI_O_DOWN——客观下线

image-20210907025536975

2、选举一个sentinel去解决当前master宕机问题

image-20210907025643655

每竞选轮回一次,竞选次数加1

3、服务器列表中挑选备选master
  • 在线的
  • 响应快的
  • 与原master断开时间短的
  • 优先原则
    • 优先级,优先级越高胜出
    • offset,offset越大胜出
    • runid,runid越小胜出
  • 发送指令( sentinel )
    • 向新的master发送slaveof no one
    • 向其他slave发送slaveof 新masterIP端口

image-20210907030402598

4、故障转移阶段总结
  • 监控
    • 同步信息
  • 通知
    • 保持联通
    • 故障转移
    • 发现问题
    • 竞选负责人
    • 优选新master
    • 新master上任,其他slave切换master,原master作为slave故障回复后连接

4、日志查看

哨兵1(sentinel1)日志:

master6379下线之后:

image-20210907031723596

master6379重新上线:

image-20210907032103510


9、集群

1、集群简介

1、现状问题

业务发展过程中遇到的峰值瓶颈:

  • redis提供的服务OPS可以达到10万/秒,当前业务OPS已经达到10万/秒
  • 内存单机容量达到256G,当前业务需求内存容量1T

使用集群的方式可以快速解决上述问题

2、集群架构

集群就是使用网络将若干台计算机联通起来,并提供统一的管理方式,使其====。

image-20210907032507122

3、集群作用

  • 分散单台服务器的访问压力,实现负载均衡
  • 分散单台服务器的存储压力,实现可扩展性
  • 降低单台服务器宕机带来的业务灾难

image-20210907032548910

4、Redis 集群的限制

  • db库:单机的Redis默认有16个db数据库,但在集群模式下只有一个db0;
  • 复制结构:上面的复制结构有树状结构,但在集群模式下只允许单层复制结构;
  • 事务/lua脚本仅允许操作的key在同一个节点上才可以在集群下使用事务或lua脚本;(使用Hash Tag可以解决)
    • 多键的Redis事务是不被支持的。
    • lua脚本不被支持
  • key的批量操作:如mget、mset操作,只有当操作的key都在同一个节点上才可以执行;(使用Hash Tag可以解决)
    • 多键操作是不被支持的
  • keys/flushall:只会在该节点之上进行操作,不会对集群的其他节点进行操作;
  • 由于集群方案出现较晚,很多公司已经采用了其他的集群方案,而代理或者客户端分片的方案想要迁移至redis cluster,需要整体迁移而不是逐步过渡,复杂度较大。
Hash Tag

上面介绍集群限制的时候,由于key被分布在不同的节点之上,因此无法跨节点做事务或lua脚本操作,但我们可以使用hash tag方式解决。

hash tag:当key包含{}的时候,不会对整个key做hash,只会对{}包含的部分做hash然后分配槽slot;因此我们可以让不同的key在同一个槽内,这样就可以解决key的批量操作和事务及lua脚本的限制了;

但由于hash tag会将不同的key分配在相同的slot中,如果使用不当,会造成数据分布不均的情况,需要注意。

img

2、Redis集群结构设计

1、数据存储设计

  • 通过算法设计,计算出key应该保存的位置

  • 将所有的存储空间计划切割成16384份,每台主机保存一部分

    • ==每份代表的是一个存储空间==,不是一个key的保存空间
  • 将key按照计算出的结果放到对应的存储空间

  • 增强可扩展性

    image-20210907033026543

原本redis的数据存储:

image-20210907032817263

经过Redis集群结构的数据存储:

image-20210907032922698

2、集群内部通讯设计(迭代查询)

  • 各个数据库相互通信,保存各个库中槽的编号数据
  • 一次命中,直接返回
  • 一次未命中,告知具体位置

image-20210907033351047

3、原理

1、数据分区规则

衡量数据分区方法的标准有两个重要因素:

  1. 是否均匀分区;
  2. 增减节点对数据分布的影响;

由于哈希算法具有随机性,可以保证数据均匀分布,因此Redis集群采用哈希分区的方式对数据进行分区,哈希分区就是对数据的特征值进行哈希,然后根据哈希值决定数据放在哪里。

2、常见的哈希分区
1、哈希取余:

计算key的hash值,对节点数量做取余计算,根据结果将数据映射到对应节点;但当节点增减时,系统中所有数据都需要重新计算映射关系,引发大量数据迁移

2、一致性哈希

将hash值区间抽象为一个环形,节点均匀分布在该环形之上,然后根据数据的key计算hash值,在该hash值所在的圆环上的位置延顺时针行走找到的第一个节点的位置,该数据就放在该节点之上。相比于哈希取余,一致性哈希分区将增减节点的影响限制为相邻节点

例:在AB节点中新增一个节点E时,因为B上的数据的key的hash值在A和B所在的hash区间之内,因此只有C上的一部分数据会迁移到B节点之上;同理如果从BCD中移除C节点,由于C上的数据的key的hash值在B和C所在的hash区间之内,因此C上的数据顺时针找到的第一个节点就是D节点,因此C的数据会全部迁移到D节点之上。 但当节点数量较少的时候,增删节点对单个节点的影响较大,会造成数据分布不均,如移除C节点时,C的数据会全部迁移到D节点上,此时D节点拥有的数据由原来的1/4变成现在的1/2,相比于节点A和B来说负载更高。

img

3、带虚拟节点的一致性哈希 (Redis集群)

Redis采用的方案,在一致性哈希基础之上,引入虚拟节点的概念,虚拟节点被称为槽(slot)。Redis集群中,槽的数量为16384。

槽介于数据和节点之间,将节点划分为一定数量的槽,每个槽包含哈希值一定范围内的数据。由原来的hash–>node 变为 hash–>slot–>node。

当增删节点时,该节点所有拥有的槽会被重新分配给其他节点,可以避免在一致性哈希分区中由于某个节点的增删造成数据的严重分布不均。

img

3、通信机制

在上面的哨兵方案中,节点被分为数据节点和哨兵节点,哨兵节点也是redis服务,但只作为选举监控使用,只有数据节点会存储数据。而在Redis集群中,所有节点都是数据节点,也都参与集群的状态维护

在Redis集群中,数据节点提供两个TCP端口,在配置防火墙时需要同时开启下面两类端口:

  • 普通端口:即客户端访问端口,如默认的6379;
  • 集群端口:普通端口号加10000,如6379的集群端口为16379,用于集群节点之间的通讯;

集群的节点之间通讯采用Gossip协议,节点根据固定频率(每秒10次)定时任务进行判断,当集群状态发生变化,如增删节点、槽状态变更时,会通过节点间通讯同步集群状态,使集群收敛

集群间发送的Gossip消息有下面五种消息类型:

  • MEET:在节点握手阶段,对新加入的节点发送meet消息,请求新节点加入当前集群,新节点收到消息会回复PONG消息;
  • PING:节点之间互相发送ping消息,收到消息的会回复pong消息。ping消息内容包含本节点和其他节点的状态信息,以此达到状态同步;
  • PONG:pong消息包含自身的状态数据,在接收到ping或meet消息时会回复pong消息,也会主动向集群广播pong消息;
  • FAIL:当一个主节点判断另一个主节点进入fail状态时,会向集群广播这个消息,接收到的节点会保存该消息并对该fail节点做状态判断;
  • PUBLISH:当节点收到publish命令时,会先执行命令,然后向集群广播publish消息,接收到消息的节点也会执行publish命令;
4、访问集群

上面介绍了槽的概念,在每个节点存储着不同范围的槽,数据也分布在不同的节点之上,我们在访问集群的时候,如何知道数据在哪个节点或者在哪个槽之上呢? 下面介绍两种访问连接:

1、Dummy客户端

使用redis-cli客户端连接集群被称为dummy客户端,只会在执行命令之后通过MOVED错误重定向找到对应的节点,如图,我们可以使用redis-cli -c命令进入集群命令行,当查看或设置key的时候会根据上面提到的CRC16算法计算key的hash值找到对应的槽slot,然后重定向到对应的节点之后才能操作,我们也使用cluster keyslot命令查看key所在的槽solt:

1
2
3
4
5
# 使用-c进入集群命令行模式
redis-cli -c -p 6381

# 使用命令查看key所在的槽
cluster keyslot key1

img

img

2、Smart客户端

相比于dummy客户端,smart客户端在初始化连接集群时就缓存了槽slot和节点node的对应关系, 也就是在连接任意节点后执行cluster slots,我们使用的JedisCluster就是smart客户端:

1
cluster slots

img

集群代理:Redis6版本中新增的特性,客户端不需要知道集群中的具体节点个数和主从身份,可以直接通过代理访问集群。与Redis在不同的分支,将在后面的文章中具体介绍。

3、cluster集群结构搭建

1、搭建方式

  • 原生安装(单条命令)
    • 配置服务器(3主3从)
    • 建立通信(Meet)
    • 分槽(Slot)
    • 搭建主从(master-slave)
  • 工具安装(批处理)

2、Cluster配置

配置一个配置文件,借助这个配置文件去配置其他类型配置文件的命令:

1
sed "s/6379/6380/g" redis-6379.conf > redis-7380.conf

将redis-6379.conf配置文件当中的6379修改为6380之后生成一个redis-6380.conf的配置文件

  • 添加节点

    1
    cluster-enabled yes|no
  • cluster配置文件名,该文件属于自动生成,仅用于快速查找文件并查询文件内容

    1
    cluster-config-file <filename>

    这里建议修改cluster的配置文件的名字,因为如果在同一个目录下有多个cluster结点的话,可能会因为相关的配置文件的同名而导致一定的问题。
    建议改名:nodes-端口.conf

  • 节点服务响应超时时间,用于判定该节点是否下线或切换为从节点

    1
    cluster-node-timeout <milliseconds>

    与后面当master宕机之后,slave日志的展示有关

    对于线上,30s或60s都行,看具体的业务

  • master连接的slave最小数量

    1
    cluster-migration-barrier <count>

3、启动redis服务

1、启动master结点
1
redis-server /redis-4.0.0/conf/redis-6379.conf

image-20210907135258255

按照上面的方法依次启动另外的五个结点(三主三从)

2、查看当前redis服务
1
ps -ef | grep redis

image-20210907135434580

3、将当前的六个结点相连

把启动的一个个redis结点进行连接

相关命令:下载的redis包下的src目录下的redis-trib.rb

要想启动redis-trib.rb,需要两个工具:

  1. ruby
  2. rubygem

需要将它们先进行下载。
注意:

  • redis的版本不同,对应下载的ruby也会有所不同
  • 如果ruby和gem的版本不够,它会提醒你升级到对应的版本

在Redis 6当中,redis-cli –cluster代替了之前的redis-trib.rb,我们无需安装ruby环境即可直接使用它附带的所有功能:创建集群、增删节点、槽迁移、完整性检查、数据重平衡等等。

redis-trib.rb命令的执行:

1
2
# 如果直接执行redis-trib.rb它是识别不出来的,而且只有在当前目录下有效,需要将它用./redis-trib.rb方式执行
./redis-trib.rb create --replicas 1 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384

当中的1表示master与slave之间的数量

  • eg:
    • 1:1个master有1个slave
    • 2:1个master有2个slave

对应的,后面的结点IP和端口需要与前面的数字相对应

  • eg:
    • 前面1,后面6:3对——1个muster1个slave
    • 前面2,后面6:2对——1个muster2个slave

image-20210907140357232

在选择yes之前,也就是生成相关配置文件之前:

image-20210907140650632

选择yes之后生成相关配置文件:

image-20210907141043430

image-20210907141310592

4、此时redis服务端的日志

master服务端:

image-20210907141859784

slave服务端:

image-20210907142218680

4、使用cluster设置与获取数据

存取数据:

  • 若是按照之前的方法启动:

    • redis-cli
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - 则你在进行set/get等操作的时候会报错,redis会告诉你当前数据应该设置在哪一个槽当中,很麻烦

      ![image-20210907142834922](redis高级/image-20210907142834922.png)

      - 所以应当商量说过的另一个启动方式:

      ```sh
      redis-cli -c
  • 再进行set/get操作,发现成功,redis会返回该值已经重定向到对应的槽当中,并且返回OK

    image-20210907142908730

    image-20210907142958347

5、在Cluster集群下出现相关问题的解决方法

在Cluster集群下测试出现的相关问题:

  1. 当slave结点宕机会出现什么问题?
  2. 当master结点宕机会出现什么问题?
1、当slave结点宕机会出现什么问题?

宕机的slave结点对应的master:

  • 宕机前:

    image-20210907143341529

  • 宕机后:

    image-20210907143626170

  • 对应的从结点重新上线:

    image-20210907143842881

其他master结点:

  • 宕机前:

    image-20210907143414489

  • 宕机后:(这里包括其他的从结点也一样)

    image-20210907143641578

  • 重新上线:

    image-20210907144153900

宕机的从结点:

  • 宕机前:

    image-20210907143504078

  • 宕机后:

    image-20210907143531076

  • 重新上线

由上面可以得到,在Cluster集群当中,当一个slave结点宕机并不会产生多大的影响,只是将相应宕机的从结点进行标记而已,整一个redis集群依旧是可用的。当宕机的slave结点重新上线之后在将它加入对应的主节点就行。

2、当master结点宕机会出现什么问题?

宕机的master结点:

  • 宕机前:

    image-20210907144324055

  • 宕机后:

    image-20210907144225353

  • 重新上线

宕机的master结点对应的slave结点:

  • 宕机前:

    image-20210907144755729

  • 宕机后:

    image-20210907144740458

    image-20210907145044928

    此时通过cluster nodes命令去查看当前cluster集群的状态:

    image-20210907145201995

    把宕机的master结点标记为fail,因为宕机的结点可能重新上线,所以这里只是做了标记

  • 重新上线:

    image-20210907145908556

    使用cluster nodes查看当前cluster集群的状态:

    image-20210907150019576

其他结点只是更新一下当前结点的状态而已

image-20210907150133315

6、Cluster节点操作命令

  • 查看集群节点信息

    1
    cluster nodes
  • 进入一个从节点 redis,切换其主节点

    1
    cluster replicate <master-id>
  • 发现一个新节点,新增主节点

    1
    cluster meet ip:port
  • 忽略一个没有solt的节点

    1
    cluster forget <id>
  • 手动故障转移

    1
    cluster failover

7、redis-trib命令

  • 添加节点

    1
    redis-trib.rb add-node
  • 删除节点

    1
    redis-trib.rb del-node
  • 重新分片

    1
    redis-trib.rb reshard

4、集群参数优化

cluster_node_timeout

  • 默认值为15s
  • 影响ping消息接收节点的选择,值越大对延迟容忍度越高,选择的接收节点就越少,可以降低带宽,但会影响收敛速度。应该根据带宽情况和实际要求具体调整。
  • 影响故障转移的判定,值越大越不容易误判,但完成转移所消耗的时间就越长。应根据网络情况和实际要求具体调整。

cluster-require-full-coverage

  • 为了保证集群的完整性,只有当16384个槽slot全部分配完毕,集群才可以上线,但同时,若主节点发生故障且故障转移还未完成时,原主节点的槽不在任何节点中,集群会处于下线状态,影响客户端的使用。
  • 该参数可以改变此设定:
    • no:表示当槽没有完全分配时,集群仍然可以上线;
    • yes:默认配置,只有槽完全分配,集群才可以上线;

10、企业级解决方案

1、缓存预热

1、“宕机”

服务器启动后迅速宕机

2、问题排查

  1. 请求数量较高
  2. 主从之间数据吞吐量较大,数据同步操作频度较高

3、解决方案

前置准备工作:

  1. 日常例行统计数据访问记录,统计访问频度较高的热点数据
  2. 利用LRU数据删除策略,构建数据留存队列
    • 例如:storm与kafka配合

准备工作:

  1. 将统计结果中的数据分类,根据级别,redis优先加载级别较高的热点数据
  2. 利用分布式多服务器同时进行数据读取,提速数据加载过程
  3. 热点数据主从同时预热

实施:

  1. 使用脚本程序固定触发数据预热过程
  2. 如果条件允许,使用了CDN(内容分发网络),效果会更好

4、结论

缓存预热就是系统启动前,提前将相关的缓存数据直接加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

2、缓存雪崩

1、数据库服务器崩溃(1)

  1. 系统平稳运行过程中,忽然数据库连接量激增
  2. 应用服务器无法及时处理请求
  3. 大量408,500错误页面出现
  4. 客户反复刷新页面获取数据
  5. 数据库崩溃
  6. 应用服务器崩溃
  7. 重启应用服务器无效
  8. Redis服务器崩溃
  9. Redis集群崩溃
  10. 重启数据库后再次被瞬间流量放倒

img

2、问题排查

  1. 在一个==较短==的时间内,缓存中==较多==的key==集中过期==
  2. 此周期内请求访问过期的数据,redis未命中,redis向数据库获取数据
  3. 数据库同时接收到大量的请求无法及时处理
  4. Redis大量请求被积压,开始出现超时现象
  5. 数据库流量激增,数据库崩溃
  6. 重启后仍然面对缓存中无数据可用
  7. Redis服务器资源被严重占用,Redis服务器崩溃
  8. Redis集群呈现崩塌,集群瓦解
  9. 应用服务器无法及时得到数据响应请求,来自客户端的请求数量越来越多,应用服务器崩溃
  10. 应用服务器,redis,数据库全部重启,效果不理想

3、问题分析

  • 短时间范围内
  • 大量key集中过期

4、解决方案(道)

  1. 更多的页面静态化处理
  2. 构建多级缓存架构
    • Nginx缓存+redis缓存+ehcache缓存
  3. 检测Mysql严重耗时业务进行优化
    • 对数据库的瓶颈排查:例如超时查询、耗时较高事务等
  4. 灾难预警机制
    • 监控redis服务器性能指标
      • CPU占用、CPU使用率
      • 内存容量
      • 查询平均响应时间
      • 线程数
  5. 限流、降级
    • 短时间范围内牺牲一些客户体验,限制一部分请求访问,降低应用服务器压力,待业务低速运转后再逐步放开访问

5、解决方案(术)

  1. LRU与LFU切换

  2. 数据有效期策略调整

    • 根据业务数据有效期进行分类错峰,A类90分钟,B类80分钟,C类70分钟

      img

    • 过期时间使用固定时间+随机值的形式,稀释集中到期的key的数量

      img

  3. 超热数据使用永久key

  4. 定期维护(自动+人工)

    • 对即将过期数据做访问量分析,确认是否延时,配合访问量统计,做热点数据的延时
  5. 加锁

    • 慎用!

6、总结

缓存雪崩就是瞬间过期数据量太大,导致对数据库服务器造成压力。如能够有效避免过期时间集中,可以有效解决雪崩现象的出现(约40%),配合其他策略一起使用,并监控服务器的运行数据,根据运行记录做快速调整

原本情况:

image-20210907151332317

服务雪崩的情况:

image-20210907151254348

3、缓存击穿

1、数据库服务器崩溃(2)

  1. 系统平稳运行过程中
  2. 数据库连接量瞬间激增
  3. Redis服务器无大量key过期
  4. Redis内存平稳,无波动
  5. Redis服务器CPU正常
  6. 数据库崩溃

image-20210908131731307

img

2、问题排查

  1. Redis中某个key过期,该key访问量巨大
  2. 多个数据请求从服务器直接压到Redis后,均未命中
  3. Redis在短时间内发起了大量对数据库中同一数据的访问

3、问题分析

  • 单个key高热数据
  • key过期

4、解决方案(术)

  1. 预先设定

    • 以电商为例,每个商家根据店铺等级,指定若干款主打商品,在购物节期间,加大此类信息key的过期时长
    • 注意:购物节不仅仅指当天,以及后续若干天,访问峰值呈现逐渐降低的趋势
  2. 现场调整

    • 监控访问量,对自然流量激增的数据延长过期时间或设置为永久性key
  3. 后台刷新数据

    • 启动定时任务,高峰期来临之前,刷新数据有效期,确保不丢失
  4. 二级缓存

    • 设置不同的失效时间,保障不会被同时淘汰就行
  5. 加锁(但是要注意也是性能瓶颈,慎重!)

    • 分布式锁,防止被击穿,

    • 利用互斥锁保证同一时刻只有一个客户端可以查询底层数据库的这个数据,一旦查到数据就缓存至Redis内,避免其他大量请求同时穿过Redis访问底层数据库;

      img

      在使用互斥锁的时候需要避免出现死锁或者锁过期的情况:

      • 使用lua脚本或事务将获取锁和设置过期时间作为一个原子性操作(如:set kk vv nx px 30000),以避免出现某个客户端获取锁之后宕机导致的锁不被释放造成死锁现象;
      • 另起一个线程监控获取锁的线程的查询状态,快到锁过期时间时还没查询结束则延长锁的过期时间,避免多次查询多次锁过期造成计算资源的浪费;

5、总结

缓存击穿就是单个高热数据过期的瞬间,数据访问量较大,未命中redis后,发起了大量对同一数据的数据库访问,导致对数据库服务器造成压力。应对策略应该在业务数据分析与预防方面进行,配合运行监控测试与即时调整策略,毕竟单个key的过期监控难度较高,配合雪崩处理策略即可。

4、缓存穿透

1、数据库服务器崩溃(3)

  1. 系统平稳运行过程中
  2. 应用服务器流量随时间增量较大
  3. Redis服务器命中率随时间逐步降低
  4. Redis内存平稳,内存无压力
  5. Redis服务器CPU占用激增
  6. 数据库服务器压力激增
  7. 数据库崩溃

image-20210908131718336

img

2、问题排查

  • Redis中大面积出现未命中
  • 出现非正常URL访问

3、问题分析

  • 获取的数据在数据库中也不存在,数据库查询未得到对应数据
  • Redis获取到null数据未进行持久化,直接返回
  • 下次此类数据到达重复上述过程
  • ==出现黑客攻击服务器==

4、解决方案(术)

  1. 缓存null

    • 对查询结果为null的数据进行缓存(长期使用,定期清理),设定短时限,例如30-60秒,最高5分钟
  2. 白名单策略

    • 提前预热各种分类数据id对应的bitmaps,id作为bitmaps的offset,相当于设置了数据白名单。当加载正常数据时,放行,加载异常数据时直接拦截(效率偏低)

    • 使用布隆过滤器(有关布隆过滤器的命中问题对当前状况可以忽略)

      • (布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量(位图)和一系列随机映射函数(哈希函数)。
      • 布隆过滤器可以用于检索一个元素是否在一个集合中
      • 它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难
      • 将所有可能存在的数据哈希到一个足够大的bitmaps中,一个一定不存在的数据会被这个bitmaps拦截掉,从而避免了对底层存储系统的查询压力

      img

      布隆过滤器有误判率,虽然不能完全避免数据穿透的现象,但已经可以将99.99%的穿透查询给屏蔽在Redis层了,极大的降低了底层数据库的压力,减少了资源浪费。

  3. 实施监控

    • 实时监控redis命中率(业务正常范围时,通常会有一个波动值)与null数据的占比
      • 非活动时段波动:通常检测3-5倍,超过5倍纳入重点排查对象
      • 活动时段波动:通常检测10-50倍,超过50倍纳入重点排查对象 根据倍数不同,启动不同的排查流程。然后使用黑名单进行防控(运营)
  4. key加密

    • 问题出现后,临时启动防灾业务key,对key进行业务层传输加密服务,设定校验程序,过来的key校验
    • 例如每天随机分配60个加密串,挑选2到3个,混淆到页面数据id中,发现访问key不满足规则,驳回数据访问

5、总结

缓存穿透访问了不存在的数据,跳过了合法数据的redis数据缓存阶段,每次访问数据库,导致对数据库服务器造成压力。通常此类数据的出现量是一个较低的值,当出现此类情况以毒攻毒,==并及时报警==。应对策略应该在临时预案防范方面多做文章。

无论是黑名单还是白名单,都是对整体系统的压力,警报解除后尽快移除。

5、缓存更新

缓存服务(Redis)和数据服务(底层数据库)是相互独立且异构的系统,在更新缓存或更新数据的时候无法做到原子性的同时更新两边的数据,因此在并发读写或第二步操作异常时会遇到各种数据不一致的问题。如何解决并发场景下更新操作的双写一致是缓存系统的一个重要知识点。

第二步操作异常:缓存和数据的操作顺序中,第二个动作报错。如数据库被更新, 此时失效缓存的时候出错,缓存内数据仍是旧版本;

缓存更新的设计模式有四种:

  • Cache aside

    • 查询:先查缓存,缓存没有就查数据库,然后加载至缓存内;

    • 更新:先更新数据库,然后让缓存失效;或者先失效缓存然后更新数据库;

    • 为了避免在并发场景下,多个请求同时更新同一个缓存导致脏数据,因此不能直接更新缓存而是另缓存失效。(看Redis 的缓存一致性)

    • 推荐使用先失效缓存,后更新数据库,配合延迟失效来更新缓存的模式;

      img

  • Read through:在查询操作中更新缓存,即当缓存失效时,Cache Aside 模式是由调用方负责把数据加载入缓存而 Read Through 则用缓存服务自己来加载

  • Write through:在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后由缓存自己更新数据库

  • Write behind caching:俗称write back,在更新数据的时候,只更新缓存,不更新数据库,缓存会异步地定时批量更新数据库

四种缓存更新模式的优缺点

  • Cache Aside:实现起来较简单,但需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository);
  • Read/Write Through:只需要维护一个数据存储(缓存),但是实现起来要复杂一些;
  • Write Behind Caching:与Read/Write Through 类似,区别是Write Behind Caching的数据持久化操作是异步的,但是Read/Write Through 更新模式的数据持久化操作是同步的。
    • 优点是直接操作内存速度快,多次操作可以合并持久化到数据库。
    • 缺点是数据可能会丢失,例如系统断电等。

缓存本身就是通过牺牲强一致性来提高性能,因此使用缓存提升性能,就会有数据更新的延迟性。这就需要我们在评估需求和设计阶段根据实际场景去做权衡了。

6、缓存降级

缓存降级是指当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,即使是有损部分其他服务,仍然需要保证主服务可用。可以将其他次要服务的数据进行缓存降级,从而提升主服务的稳定性。

降级的目的是保证核心服务可用,即使是有损的。如去年双十一的时候淘宝购物车无法修改地址只能使用默认地址,这个服务就是被降级了,这里阿里保证了订单可以正常提交和付款,但修改地址的服务可以在服务器压力降低,并发量相对减少的时候再恢复。

降级可以根据实时的监控数据进行自动降级也可以配置开关人工降级。是否需要降级,哪些服务需要降级,在什么情况下再降级,取决于大家对于系统功能的取舍。

7、性能指标监控

1、监控指标

  • 性能指标:Performance
  • 内存指标:Memory
  • 基本活动指标:Basic activity
  • 持久性指标:Persistence
  • 错误指标:Error
1、性能指标:Performance
Name Description
latency Redis响应一个请求的时间
instantaneous_ops_per_sec 平均每秒处理请求总数
hit rate(calculated) 缓存命中率(计算出来的)
2、内存指标:Memory
Name Description
used_menory 已使用内存
mem_fragmentation_ratio 内存碎片率
evicted_keys 由于最大内存限制被移除的key的数量
blocked_clients 由于BLPOP,BRPOP,or BRPOPlPUSH而备阻塞的客户端
3、基本活动指标:Basic activity
Name Description
connected_clients 客户端连接数
connected_slaves Slave数量
master_last_io_seconds_ago 最近一次主从交互之后的秒数
keyspace 数据库中的key值总数
4、持久性指标:Persistence
Name Description
rdb_last_save_time 最后一次持久化保存到磁盘的时间戳
rdb_changes_since_last_save 自最后一次持久化以来数据库的更改数
5、错误指标:Error
Name Description
rejected_connections 由于达到maxclient限制而被拒绝的连接数
keyspace_misses Key值查找失败(没有命中)次数
master_link_down_since_seconds 主从断开的持续时间

2、redis相关的工具与监控命令

  • 工具
    • Cloud Insight Redis
    • Prometheus
    • Redis-stat
    • Redis-faina
    • RedisLive
    • zabbix
  • 命令
    • benchmark
    • redis cli
    • monitor
    • showlog
1、命令——benchmark

注意:

  • benchmark是一个指令,而不是一个redis命令

  • 所以不是在redis的客户端上启动的,而是像启动redis的服务端或客户端那样直接执行的

  • 命令

    1
    redis-benchmark [-h ] [-p ] [-c ] [-n <requests]> [-k ]
  • 范例1

    1
    redis-benchmark
  • 范例2

    1
    redis-benchmark -c 100 -n 5000

    说明:100个连接,5000次请求对应的性能

image-20210907163642955

2、命令——monitor

注意:

  • monitor它是一个redis命令,而不是一个指令

  • 所以需要在redis的客户端上启动的

  • 命令

    1
    monitor

    打印服务器调试信息

3、命令——showlong
  • 命令

    1
    showlong [operator]

    operator:

    • get :获取慢查询日志
    • len :获取慢查询日志条目数
    • reset :重置慢查询日志
  • 相关配置

    1
    2
    slowlog-log-slower-than 1000 #设置慢查询的时间下线,单位:微妙
    slowlog-max-len 100 #设置慢查询命令对应的日志显示长度,单位:命令数

3、Redis 6

1、NoSQL数据库简介

1、技术发展

技术的分类

  1. 解决功能性的问题:Java、Jsp、RDBMS、Tomcat、HTML、Linux、JDBC、SVN
  2. 解决扩展性的问题:Struts、Spring、SpringMVC、Hibernate、Mybatis
  3. 解决性能的问题:NoSQL、Java线程、Hadoop、Nginx、MQ、ElasticSearch

1、Web 1.0时代

Web1.0的时代,数据访问量很有限,用一夫当关的高性能的单点服务器可以解决大部分问题。

image-20210907203723504

2、Web 2.0时代

随着Web2.0的时代的到来,用户访问量大幅度提升,同时产生了大量的用户数据。加上后来的智能移动设备的普及,所有的互联网平台都面临了巨大的性能挑战。

image-20210907203751485

3、解决CPU及内存压力

image-20210907214051175

4、解决IO压力

image-20210907214058698

2、NoSQL数据库

1、NoSQL数据库概述

NoSQL(NoSQL = Not Only SQL ),意即“不仅仅是SQL”,泛指非关系型的数据库

NoSQL 不依赖业务逻辑方式存储,而以简单的key-value模式存储。因此大大的增加了数据库的扩展能力。

  • 不遵循SQL标准。
  • 不支持ACID。(事务的四大特性)
  • 远超于SQL的性能。

2、NoSQL适用场景

  • 对数据高并发的读写
  • 海量数据的读写
  • 对数据高可扩展性的

3、NoSQL不适用场景

  • 需要事务支持
  • 基于sql的结构化查询存储,处理复杂的关系,需要即席查询
    • 即席查询是用户根据自己的需求,灵活的选择查询条件,系统能够根据用户的选择生成相应的统计报表
  • (用不着sql的和用了sql也不行的情况,请考虑用NoSql)

4、常用的NoSQL数据库

1、Memcache
  • 很早出现的NoSql数据库
  • 数据都在内存中,一般不持久化
  • 支持简单的key-value模式,支持类型单一
  • 一般是作为缓存数据库辅助持久化的数据库
2、Redis

几乎覆盖了Memcached的绝大部分功能

数据都在内存中,支持持久化,主要用作备份恢复

除了支持简单的key-value模式,还支持多种数据结构的存储,比如 list、set、hash、zset等。

一般是作为缓存数据库辅助持久化的数据库

3、MongoDb
  • 高性能、开源、模式自由(schema free)的文档型数据库
  • 数据都在内存中, 如果内存不足,把不常用的数据保存到硬盘
  • 虽然是key-value模式,但是对value(尤其是json)提供了丰富的查询功能
  • 支持二进制数据及大型对象
  • 可以根据数据的特点替代RDBMS ,成为独立的数据库。或者配合RDBMS,存储特定的数据。

3、行式存储数据库(大数据时代)

1、行式数据库

image-20210907215836602

2、列式数据库

image-20210907215842362

1、Hbase

HBase是Hadoop项目中的数据库。它用于需要对大量的数据进行==随机==、==实时==的读写操作的场景中。

HBase的目标就是处理数据量非常庞大的表,可以用普通的计算机处理超过10亿行数据,还可处理有数百万元素的数据表。

2、Cassandra[kəˈsændrə]

Apache Cassandra是一款免费的开源NoSQL数据库,其设计目的在于管理由大量商用服务器构建起来的庞大集群上的**海量数据集(数据量通常达到PB级别)**。在众多显著特性当中,Cassandra最为卓越的长处是对写入及读取操作进行规模调整,而且其不强调主集群的设计思路能够以相对直观的方式简化各集群的创建与扩展流程。

计算机存储单位 计算机存储单位一般用B,KB,MB,GB,TB,EB,ZB,YB,BB来表示,它们之间的关系是:

位 bit (比特)(Binary Digits):存放一位二进制数,即 0 或 1,最小的存储单位。

字节 byte:8个二进制位为一个字节(B),最常用的单位。

1KB (Kilobyte 千字节)=1024B,

1MB (Megabyte 兆字节 简称“兆”)=1024KB,

1GB (Gigabyte 吉字节 又称“千兆”)=1024MB,

1TB (Trillionbyte 万亿字节 太字节)=1024GB,其中1024=2^10 ( 2 的10次方),

1PB(Petabyte 千万亿字节 拍字节)=1024TB,

1EB(Exabyte 百亿亿字节 艾字节)=1024PB,

1ZB (Zettabyte 十万亿亿字节 泽字节)= 1024 EB,

1YB (Jottabyte 一亿亿亿字节 尧字节)= 1024 ZB,

1BB (Brontobyte 一千亿亿亿字节)= 1024 YB.

注:“兆”为百万级数量单位。

4、图关系型数据库

主要应用:社会关系,公共交通网络,地图及网络拓谱(n*(n-1)/2)

image-20210907220207887

5、DB-Engines 数据库排名

http://db-engines.com/en/ranking

image-20210907220243094


2、Redis配置文件介绍

自定义目录:/myredis/redis.conf

1、###Units单位###

配置大小单位,开头定义了一些基本的度量单位,只支持bytes,不支持bit

大小写不敏感

image-20210908000334131

2、###INCLUDES包含###

image-20210908000425633

类似jsp中的include,多实例的情况可以把公用的配置文件提取出来

3、###网络相关配置

1、bind

默认情况bind=127.0.0.1只能接受本机的访问请求

不写的情况下,无限制接受任何ip地址的访问

生产环境肯定要写你应用服务器的地址;服务器是需要远程访问的,所以需要将其注释掉

如果开启了protected-mode,那么在没有设定bind ip且没有设密码的情况下,Redis只允许接受本机的响应

如果配置了bind * -::*:表示无限制接受任何ip地址的访问

image-20210908000556177

保存配置,停止服务,重启启动查看进程,不再是本机访问了。

image-20210908000618247

2、protected-mode

将本机访问保护模式设置no

默认为yes,表示只能进行本机访问

image-20210908000707970

3、port

端口号,默认 6379

image-20210908000750174

4、tcp-backlog

设置tcp的backlog,backlog其实是一个连接队列,backlog队列总和=未完成三次握手队列 + 已经完成三次握手队列。

在高并发环境下你需要一个高backlog值来避免慢客户端连接问题。默认是511

注意Linux内核会将这个值减小到/proc/sys/net/core/somaxconn的值(128),所以需要确认增大/proc/sys/net/core/somaxconn/proc/sys/net/ipv4/tcp_max_syn_backlog(128)两个值来达到想要的效果

image-20210908000853907

5、timeout

一个空闲的客户端维持多少秒会关闭,0表示关闭该功能。即永不关闭

默认为0,单位:秒/s

image-20210908001813330

6、tcp-keepalive

对访问客户端的一种心跳检测,每个n秒检测一次。默认是300s

单位为秒,如果设置为0,则不会进行Keepalive检测,建议设置成60

image-20210908002103758

4、###GENERAL通用###

1、daemonize

是否为后台进程,设置为yes

守护进程,后台启动

image-20210908002228723

2、pidfile

存放pid文件的位置,每个实例会产生一个不同的pid文件

image-20210908002305616

3、loglevel

指定日志记录级别,Redis总共支持四个级别:debug、verbose、notice、warning,默认为notice

四个级别根据使用阶段来选择,生产环境选择notice 或者warning

image-20210908002345229

4、logfile

日志文件名称,默认为空

image-20210908002458862

5、databases 16

设定库的数量 默认16默认数据库为0,可以使用SELECT <dbid>命令在连接上指定数据库id

image-20210908002536352

5、###SECURITY安全###

设置密码

image-20210908002619005

访问密码的查看、设置和取消。默认是没有密码的

在命令中设置密码,只是临时的。重启redis服务器,密码就还原了。

永久设置,需要再配置文件中进行设置。

image-20210908002648683

6、####LIMITS限制###

1、maxclients

  • 设置redis同时可以与多少个客户端进行连接。
  • 默认情况下为10000个客户端。
  • 如果达到了此限制,redis则会拒绝新的连接请求,并且向这些连接请求方发出“max number of clients reached”以作回应。

image-20210908002803930

2、maxmemory

  • 建议必须设置,否则,将内存占满,造成服务器宕机
  • 设置redis可以使用的内存量。一旦到达内存使用上限,redis将会试图移除内部数据,移除规则可以通过maxmemory-policy来指定。(逐出算法)
  • 如果redis无法根据移除规则来移除内存中的数据,或者设置了“不允许移除”,那么redis则会针对那些需要申请内存的指令返回错误信息,比如SET、LPUSH等。
  • 但是对于无内存申请的指令,仍然会正常响应,比如GET等。如果你的redis是主redis(说明你的redis有从redis),那么在设置内存使用上限时,需要在系统中留出一些内存空间给同步队列缓存,只有在你设置的是“不移除”的情况下,才不用考虑这个因素。

image-20210908003003737

3、maxmemory-policy

  • volatile-lru:使用LRU算法移除key,只对设置了过期时间的键;(最近最少使用)
  • allkeys-lfu:在所有集合key中,使用LFU算法移除key(最近使用次数最少)
  • volatile-random:在过期集合中移除随机的key,只对设置了过期时间的键
  • allkeys-random:在所有集合key中,移除随机的key
  • volatile-ttl:移除那些TTL值最小的key,即那些最近要过期的key
  • noeviction:不进行移除。针对写操作,只是返回错误信息

image-20210908004811841

4、maxmemory-samples

  • 设置样本数量,LRU算法和最小TTL算法都并非是精确的算法,而是估算值,所以你可以设置样本的大小,redis默认会检查这么多个key并选择其中LRU的那个。
  • 一般设置3到7的数字,数值越小样本越不准确,但性能消耗越小。

image-20210908004839416


3、Redis的发布和订阅

1、什么是发布和订阅

Redis 发布订阅 (pub/sub) 是一种消息通信模式:发送者 (pub) 发送消息,订阅者 (sub) 接收消息。

Redis 客户端可以订阅任意数量的频道。

2、Redis的发布和订阅

Redis中的订阅、发布实现了发布/订阅消息范式,发布者不是计划发送消息给特定的订阅者,而是发布消息到不同的频道,发布者不需要知道是哪些订阅者订阅了消息订阅者对一个或多个频道感兴趣,只需接收感兴趣的消息,不需要知道是什么样的发布者发布的消息。这种发布者和订阅者的解耦合可以带来更大的扩展性和更加动态的网络拓扑。

在Redis的发布订阅模式中,有三个部分:

  • Publisher(发布者):发送消息到频道中,每次只能往一个频道发送一条消息;
  • Subscriber(订阅者):订阅频道,订阅者可以同时订阅多个频道;
  • Channel(频道):将发布者发布的消息转发给当前订阅此频道的订阅者

img

  1. 客户端可以订阅频道如下图

    image-20210908014220373

  2. 当给这个频道发布消息后,消息就会发送给订阅的客户端

    image-20210908014312523

3、发布订阅命令行实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 发布消息到指定的频道
PUBLISH channel message

# 订阅给定的一个或多个频道的信息
SUBSCRIBE channel [channel ...]

# 订阅一个或多个符合给定模式的频道
PSUBSCRIBE pattern [pattern ...]

# 指退订给定的频道
UNSUBSCRIBE [channel [channel ...]]

# 退订所有给定模式的频道
PUNSUBSCRIBE [pattern [pattern ...]]

# 查看订阅与发布系统状态
PUBSUB subcommand [argument [argument ...]]

4、使用

使用搭建的集群来测试Redis的订阅发布模式,A节点作为发布者,A,B,C节点作为订阅者消费A节点发布的消息:

  • 订阅者6381:与发布者在同一节点,订阅www,csdn,wyk三个频道;
  • 订阅者6382:订阅符合csdn和wyk模式的所有频道;
  • 订阅者6383:订阅csdn频道;
  • 发布者6381:分别往csdn1,csdn2,csdn,wyk四个频道发送消息,验证三个订阅者接收消息的情况以及发布者发布消息后的返回值;

img

断开后的订阅者重新订阅后会丢失断开期间发布者发布的消息:

img

在集群模式中,发布者发布消息后的返回值取决于订阅者与发布者在不在同一个节点上

  • 发布者发布消息后返回值为与发布者相同节点当前订阅了该频道的客户端数量

5、对比

在上面的示例中,大家也可以看到,Redis中的发布订阅非常像消息队列,但还是有不同,我们就来对比一下Redis的List实现消息队列以及传统消息队列Kafka看看有哪些不同:

1、对比List

与Redis中的List对比,基于List实现的消息队列需要结合lpush + brpop来实现。

  • (多消费组)
    • 当多个客户端同时消费同一个List消息队列时,消费者A使用brpop消费的数据就从list中弹出了,消费者B就再也读不到该数据;
    • 而在发布订阅中,多个订阅者可以订阅相同的频道,频道内的数据会分发到各个订阅者,不会出现某一个订阅者消费了之后,另一个订阅者读不到该数据的情况。
  • (断点消费)
    • 但对于List的消息队列来说,当消费者断开后重连,仍然可以从List中断点消费还没消费的数据;
    • 而发布订阅中,如果订阅者断开重连,会丢失断开期间发布者发布的数据,无法恢复。

2、对比Kafka

Redis的发布订阅以及List并不是要和专业的消息队列对标,而是可以实现类似的功能,真正在消息队列领域做的好的有很多,RabbitMQ、ActiveMQ、RocketMQ、Kafka、Pulsar等等,发布订阅相比于它们有什么异同呢?

  • 不同点
    • 持久化:Kafka会将数据持久化到磁盘内,而Redis的发布订阅做不到;
    • 断点消费:上面也提到,当订阅者断开重连会丢失断开期间发布者发布的消息,而kafka中会记录每个消费者消费的topic的offset,因此kafka可以从断开的offset继续消费;
    • 偏移量:基于上一条,同样的kafka的消费者可以指定从某个offset开始重新消费,而Redis发布订阅根本不会记录订阅者消费的偏移量;
    • 消费方式在Redis发布订阅中,数据消费情况是由发布者控制的,当发布者发布到频道中后,只有当前连接了频道的订阅者才能消费到数据,断开重连的会失去那部分数据。而kafka中消费进度是由消费者控制的,消费者从topic中拉取数据并记录消费的offset。
  • 相同点
    • 消息模型:在JMS消息模型中有点对点和订阅发布两种,Kafka和Redis发布订阅都是采用发布订阅的模型。
    • 消费者组:Kafka里在不同的消费者组中的消费者消费相同的topic时会各自维护一个offset,因此不会出现A消费之后的数据,B就消费不到的情况。Redis中订阅者订阅相同的频道也不会出现类似的情况。

4、解决库存遗留问题——LUA脚本

1、LUA脚本

Lua 是一个小巧的脚本语言,Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,Lua并没有提供强大的库,一个完整的Lua解释器不过200k,所以Lua不适合作为开发独立应用程序的语言,而是作为嵌入式脚本语言。

很多应用程序、游戏使用LUA作为自己的嵌入式脚本语言,以此来实现可配置性、可扩展性。

这其中包括魔兽争霸地图、魔兽世界、博德之门、愤怒的小鸟等众多游戏插件或外挂。

https://www.w3cschool.cn/lua/

2、LUA脚本在Redis中的优势

将复杂的或者多步的redis操作,写为一个脚本,一次提交给redis执行,减少反复连接redis的次数。提升性能。

LUA脚本是类似redis事务,有一定的原子性,不会被其他命令插队,可以完成一些redis事务性的操作。

但是注意redis的lua脚本功能,只有在Redis 2.6以上的版本才可以使用

利用lua脚本淘汰用户,解决超卖问题。

redis 2.6版本以后,通过lua脚本解决争抢问题,实际上是redis 利用其单线程的特性,用任务队列的方式解决多任务并发问题

image-20210908024325395

3、在Redis中使用LUA脚本示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
local userid=KEYS[1]; 
local prodid=KEYS[2];
local qtkey="sk:"..prodid..":qt";
local usersKey="sk:"..prodid.":usr';
local userExists=redis.call("sismember",usersKey,userid);
if tonumber(userExists)==1 then
return 2;
end
local num= redis.call("get" ,qtkey);
if tonumber(num)<=0 then
return 0;
else
redis.call("decr",qtkey);
redis.call("sadd",usersKey,userid);
end
return 1;

5、Redis 6新功能

1、ACL

1、简介

Redis ACL是 Access Control List(访问控制列表)的缩写,该功能允许根据可以执行的命令和可以访问的键来限制某些连接。

在Redis 5版本之前,Redis 安全规则只有密码控制,还有通过rename 来调整高危命令比如 flushdb , KEYS* , shutdown 等。Redis 6 则提供ACL的功能对用户进行更细粒度的权限控制 :

  1. 接入权限:用户名和密码
  2. 可以执行的命令
  3. 可以操作的 KEY

参考官网

2、命令

  • 展现用户权限列表

    1
    acl list

    数据说明:

    image-20210908132433758

  • 查看添加权限指令类别

    1
    2
    # 加参数类型名可以查看类型下具体命令
    acl cat <参数类型名>
    1. 查看添加权限指令类别

      image-20210908132718079

    2. 加参数类型名可以查看类型下具体命令

      image-20210908132730836

  • 查看当前用户

    1
    acl whoami
  • 创建和编辑用户ACL

    1
    aclsetuser

3、ACL规则

1、有效ACL规则的列表

某些规则只是用于激活或删除标志,或对用户ACL执行给定更改的单个单词。其他规则是字符前缀,它们与命令或类别名称、键模式等连接在一起。

类型 参数 说明
启动用户 on 激活某用户账号
禁用用户 off 禁用某用户账号。注意:已验证的连接仍然可以工作。如果默认用户被标记为off,则新连接将在未进行身份验证的情况下启动,并要求用户使用AUTH选项发送AUTH或HELLO,以便以某种方式进行身份验证。
权限的添加删除 + 将指令添加到用户可以调用的指令列表中
- 从用户可执行指令列表移除指令
+@ 添加该类别中用户要调用的所有指令,有效类别为@admin、@set、@sortedset…等,通过调用ACL CAT命令查看完整列表。特殊类别@all表示所有命令,包括当前存在于服务器中的命令,以及将来将通过模块加载的命令。
-@ 从用户可调用指令中移除类别
allcommands +@all的别名
nocommand -@all的别名
可操作键的添加或删除 ~ 添加可作为用户可操作的键的模式。例如~*允许所有的键
2、查看ACL的有哪些命令
1
help @server

img

3、通过命令创建新用户默认权限
1
acl setuser user1

image-20210908133631954

在上面的示例中,我根本没有指定任何规则。如果用户不存在,这将使用just created的默认属性来创建用户。如果用户已经存在,则上面的命令将不执行任何操作。

4、设置有用户名、密码、ACL权限、并启用的用户
1
acl setuser user2 on >password ~cached:* +get

image-20210908133725918

5、切换用户,验证权限

image-20210908133748625

2、IO 多线程

1、简介

Redis6终于支撑多线程了,告别单线程了吗?

IO多线程其实指客户端交互部分网络IO交互处理模块多线程,而非执行命令多线程

**Redis6执行命令依然是==单线程==**。

2、原理架构

Redis 6 加入多线程,但跟 Memcached 这种从 IO处理到数据访问多线程的实现模式有些差异。

Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程

之所以这么设计是不想因为多线程而变得复杂,需要去控制 key、lua、事务,LPUSH/LPOP 等等的并发问题。

整体的设计大体如下:

image-20210908134006188

另外,多线程IO默认也是不开启的,需要再配置文件中配置

1
2
3
io-threads-do-reads yes 

io-threads 4

3、工具支持 Cluster

之前老版Redis想要搭集群需要单独安装ruby环境Redis 5 将 redis-trib.rb 的功能集成到 redis-cli

另外官方 redis-benchmark 工具开始支持 cluster 模式了,通过多线程的方式对多个分片进行压测。

image-20210908134140316

4、Redis新功能持续关注

Redis6新功能还有:

  1. RESP3新的 Redis 通信协议:优化服务端与客户端之间通信
  2. Client side caching客户端缓存:基于 RESP3 协议实现的客户端缓存功能。为了进一步提升缓存的性能,将客户端经常访问的数据cache到客户端。减少TCP网络交互。
  3. Proxy集群代理模式:Proxy 功能,让 Cluster 拥有像单实例一样的接入方式,降低大家使用cluster的门槛。不过需要注意的是代理不改变 Cluster 的功能限制,不支持的命令还是不会支持,比如跨 slot 的多Key操作。
  4. Modules API
    • Redis 6中模块API开发进展非常大,因为Redis Labs为了开发复杂的功能,从一开始就用上Redis模块。
    • Redis可以变成一个框架,利用Modules来构建不同系统,而不需要从头开始写然后还要BSD许可。
    • Redis一开始就是一个向编写各种系统开放的平台。

6、Redis 的缓存一致性

1、Redis 数据一致性简介

在做系统优化时,想到了将数据进行分级存储的思路。因为在系统中会存在一些数据,有些数据的实时性要求不高,比如一些配置信息。

基本上配置了很久才会变一次。而有一些数据实时性要求非常高,比如订单和流水的数据。所以这里根据数据要求实时性不同将数据分为三级:

  • 第1级:订单数据和支付流水数据;这两块数据对实时性和精确性要求很高,所以不添加任何缓存,读写操作将直接操作数据库
  • 第2级:用户相关数据;这些数据和用户相关,具有读多写少的特征,所以我们使用redis进行缓存
  • 第3级:支付配置信息;这些数据和用户无关,具有数据量小,频繁读,几乎不修改的特征,所以我们使用本地内存进行缓存

但是只要使用到缓存,无论是本地内存做缓存还是使用 redis 做缓存,那么就会存在数据同步的问题,因为配置信息缓存在内存中,而内存时无法感知到数据在数据库的修改。这样就会造成数据库中的数据与缓存中数据不一致的问题。

接下来就讨论一下关于保证缓存和数据库双写时的数据一致性

2、解决方案

那么我们这里列出来所有策略,并且讨论他们优劣性。

  1. 先更新数据库,后更新缓存
  2. 先更新数据库,后删除缓存
  3. 先删除缓存,后更新数据库
  4. 先更新缓存,后更新数据库
  5. 数据异步同步(最佳实现)

3、详解

1、先更新数据库,后更新缓存(不推荐)

这种场景一般是没有人使用的,主要原因是在==更新缓存==那一步,为什么呢?

  1. 其一:因为有的业务需求缓存中存在的值并不是直接从数据库中查出来的,有的是需要经过一系列计算来的缓存值,那么这时候后你要更新缓存的话其实代价是很高的。如果此时有大量的对数据库进行写数据的请求,但是读请求并不多,那么此时如果每次写请求都更新一下缓存,那么性能损耗是非常大的。
    • 例子:比如在数据库中有一个值为 1 的值,此时我们有 10 个请求对其每次加一的操作,但是这期间并没有读操作进来,如果用了先更新数据库的办法,那么此时就会有十个请求对缓存进行更新,会有大量的冷数据产生,如果我们不更新缓存而是删除缓存,那么在有读请求来的时候那么就会只更新缓存一次。
  2. 其二:存在缓存数据和数据库数据不一致情况
    • 例子:当有两个线程A、B,同时对一条数据进行操作,一开始数据库和redis的数据都为tony,当线程A去修改数据库,将tong改为allen,然后线程A在修改缓存中的数据,可能因为网络原因出现延迟,这个时候线程B,将数据库中的数据修改成了Mike、然后将redis中的tony,也改成了Mike,然后线程A恢复正常,将redis中的缓存改成了allen,此时就出现了缓存数据和数据库数据不一致情况。
    • 这种情况是很致命的,因为在这个值被重新修改或过期之前,A和B读取的都是错误数据。
2、先更新缓存,后更新数据库(不推荐)

这一种情况和第一种情况是一样的,主要原因是在==更新缓存==那一步:

  1. 其一:缓存的数据可能需要经过计算,如果每次写请求都更新一下缓存,那么性能损耗是非常大的。
  2. 其二:存在缓存数据和数据库数据不一致情况
    • 当有两个线程A、B,同时对一条数据进行操作,线程A先将redis中 的数据修改为了allen,然后CPU切换到了线程B,将redis中的数据修改为了mike,然后将数据库中的信息也修改了mike,然后线程A获得CPU执行,将数据库中的信息改为了allen,此时出现缓存和数据库数据不一致情况。
3、先删除缓存,后更新数据库(存在问题)(推荐)

该方案也会出问题,具体出现的原因如下:

先删除缓存,后更新数据库

此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)

  1. 请求 A 会先删除 Redis 中的数据,然后去数据库进行更新操作
  2. 此时请求 B 看到 Redis 中的数据时空的,会去数据库中查询该值,补录到 Redis 中
  3. 但是此时请求 A 并没有更新成功,或者事务还未提交,那么这时候就会产生数据库和 Redis 数据不一致的问题

如何解决呢?

  • 其实最简单的解决办法就是==延时双删==的策略。

延时双删

但是上述的保证事务提交完以后再进行删除缓存还有一个问题:

  • 就是如果你使用的是 Mysql 的读写分离的架构的话,那么其实主从同步之间也会有时间差。

主从同步时间差

此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)

  1. 请求 A 更新操作,删除了 Redis
  2. 请求主库进行更新操作,主库与从库进行同步数据的操作
  3. 请 B 查询操作,发现 Redis 中没有数据
  4. 去从库中拿去数据
  5. 此时同步数据还未完成,拿到的数据是旧数据

此时的解决办法:如果是对 Redis 进行填充数据的查询数据库操作,那么就强制将其指向主库进行查询

从主库中拿数据

4、先更新数据库,后删除缓存(推荐)

问题:这一种情况也会出现问题:

  1. 可能会短暂出现数据不一致情况,但最终都会一致
    • 当有两个线程A、B,线程A先去将数据库的值修改为allen,然后需要去删除redis中的缓存,当线程B去读取缓存时,线程A已经完成delete操作时,缓存不命中,需要去查询数据库,然后在更新缓存,数据一致性;如果线程A没有完成delete操作,线程B直接命中,返回的数据与数据库中的数据不一致,可能会短暂出现数据不一致情况,但最终都会一致。
    • 当有两个线程A、B,线程A去修改数据库中的值改为allen,然后出现网络波动,线程B将数库中的值修改为了Mike,然后两个线程都会删除缓存,保证数据一致性
  2. 当数据过期或者初始化时,会出现数据不一致情况,也就是线程B从数据库中,查询到数据为tony,然后线程A将tony修改为了allen,然后去删除redis中的数据,然后线程B将读到的tony,更新到了数据库中,出现了数据不一致问题
    • 解决方案:对于不过期的数据我们要在上线的时候做好数据的预热,保证缓存命中。对于存在过期的数据,因为有过期时间,只会在特定的时间段内数据不一致,下次数据过期后,可以恢复,对于实时性要求不高时,可以接受。
  3. 更新数据库成功了,但是在删除缓存的阶段出错了没有删除成功,那么此时再读取缓存的时候每次都是错误的数据了。

先更新数据库,后删除缓存

此时解决方案就是利用消息队列进行删除的补偿。具体的业务逻辑用语言描述如下:

  1. 请求 A 先对数据库进行更新操作
  2. 在对 Redis 进行删除操作的时候发现报错,删除失败
  3. 此时将Redis 的 key 作为消息体发送到消息队列中
  4. 系统接收到消息队列发送的消息后再次对 Redis 进行删除操作

但是这个方案会有一个缺点就是会对业务代码造成大量的侵入,深深的耦合在一起,所以这时会有一个优化的方案,我们知道对 Mysql 数据库更新操作后再 binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。

这也是第5种解决方法:数据异步同步

5、数据异步同步(最佳实现)

对 Mysql 数据库更新操作后再 binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。

Canal:基于数据库增量日志解析,提供增量数据订阅和消费https://github.com/alibaba/canal

mysql会将操作记录在Binary log日志中,通过canal去监听数据库日志二进制文件,解析log日志,同步到redis中进行增删改操作。

canal的工作原理:canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议;MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal );canal 解析 binary log 对象(原始为 byte 流)。

利用订阅 binlog 删除缓存

4、总结

每种方案各有利弊,比如在第二种先删除缓存,后更新数据库这个方案我们最后讨论了要更新 Redis 的时候强制走主库查询就能解决问题,那么这样的操作会对业务代码进行大量的侵入,但是不需要增加的系统,不需要增加整体的服务的复杂度。

最后一种方案我们最后讨论了利用订阅 binlog 日志进行搭建独立系统操作 Redis,这样的缺点其实就是增加了系统复杂度。其实每一次的选择都需要我们对于我们的业务进行评估来选择,没有一种技术是对于所有业务都通用的。没有最好的,只有最适合我们的。

7、LUA脚本

1、介绍

Redis2.6之后新增的功能,我们可以在redis中通过lua脚本操作redis。与事务不同的是事务是将多个命令添加到一个执行的集合,执行的时候仍然是多个命令,会受到其他客户端的影响,而脚本会将多个命令和操作当成一个命令在redis中执行,也就是说该脚本在执行的过程中,不会被任何其他脚本或命令打断干扰。正是因此这种原子性,lua脚本才可以代替multi和exec的事务功能。同时也是因此,在lua脚本中不宜进行过大的开销操作,避免影响后续的其他请求的正常执行。

2、使用lua脚本的好处

  • lua脚本是作为一个整体执行的,所以中间不会被其他命令插入
  • 可以把多条命令一次性打包,所以可以有效减少网络开销;
  • lua脚本可以常驻在redis内存中,所以在使用的时候,可以直接拿来复用。也减少了代码量

3、应用

redis脚本使用eval命令执行lua脚本,其中numkeys表示lua script里有多少个key参数,redis脚本根据该数字从后面的key和arg中取前n个作为key参数,之后的都作为arg参数:

1
eval script numkeys key [key ...] arg [arg ...]

1、例1:记录IP登录次数

1
2
3
4
5
# 利用hash记录所有登录的IP次数
# key参数的数量必须和numkey一致,使用key或者argv可以实现一样的效果。如下面第一个命令里用了三个key,代表后面的三个参数分别对应脚本里的key1 key2 key3.第二个命令里用了一个key,代表了后面第一个参数对应脚本里的key1,后面第二和第三个参数对应脚本里的argv1和argv2
eval "return redis.call('hincrby', KEYS[1], KEYS[2], KEYS[3])" 3 h_host host_192.168.145.1 1

eval "return redis.call('hincrby', KEYS[1], ARGV[1], ARGV[2])" 1 h_host host_192.168.145.1 1

img

2、例2:当10秒内请求3次后拒绝访问

1
2
3
4
5
# 1.给访问ip的key递增
# 2.判断该访问次数若为首次登录则设置过期时间10
# 3.若不是首次登录则判断是否大于3次,若大于则返回0,否则返回1

eval "local request_times = redis.call('incr',KEYS[1]);if request_times == 1 then redis.call('expire',KEYS[1], ARGV[1]) end;if request_times > tonumber(ARGV[2]) then return 0 end return 1;" 1 test_127.0.0.1 10 3

img

通过上面的例子也可以看出,我们可以在redis里使用eval命令调用lua脚本,且该脚本在redis里作为单条命令去执行不会受到其余命令的影响,非常适用于高并发场景下的事务处理。同样我们可以在lua脚本里实现任何想要实现的功能,迭代,循环,判断,赋值 都是可以的。

4、lua脚本缓存

redis脚本也支持将脚本进行持久化,这样的话,下次再使用就不用输入那么长的lua脚本了。事实上使用eval执行的时候也会缓存,eval与load不同的是eval会将lua脚本执行并缓存,而load只会将脚本缓存

相同点是它们都使用sha算法进行缓存,因此只要lua脚本内容相同,eval与load缓存的sha码就是一样的。而缓存后的脚本,我们可以使用evalsha命令直接调用,极大的简化了我们的代码量,不用重复的将lua脚本写出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#eval 执行脚本并缓存
eval script numkeys key [key ...] arg [arg ...]

#load 缓存lua脚本
SCRIPT LOAD script

#使用缓存的脚本sha码调用脚本
EVALSHA sha1 numkeys key [key ...] arg [arg ...]

#使用sha码判断脚本是否已缓存
SCRIPT EXISTS sha1 [sha1 ...]

#清空所有缓存的脚本
SCRIPT FLUSH

#杀死当前正在执行的所有lua脚本
SCRIPT KILL

img

img

8、Redis6新特性之ACL安全策略(用户权限管理)

自从Redis6.0以来,大家呼吁了很久的权限管理功能**(ACL[access control list 访问控制列表])终于发布了,通过此功能,我们可以设置不同的用户并对他们授权命令或数据权限。这样我们可以避免有些用户的误操作导致数据丢失或避免数据泄露**的安全风险。

1、介绍

在Redis6之前的版本,我们只能使用requirepass参数给default用户配置登录密码,同一个redis集群的所有开发都共享default用户,难免会出现误操作把别人的key删掉或者数据泄露的情况,那之前我们也可以使用rename command的方式给一些危险函数重命名或禁用,但是这样也防止不了自己的key被其他人访问

因此Redis6版本推出了ACL(Access Control List)访问控制权限的功能,基于此功能,我们可以设置多个用户,并且给每个用户单独设置命令权限和数据权限。 为了保证向下兼容,Redis6保留了default用户和使用requirepass的方式给default用户设置密码,默认情况下default用户拥有Redis最大权限,我们使用redis-cli连接时如果没有指定用户名,用户也是默认default。

我们可以在配置文件中或者命令行中设置ACL,如果使用配置config文件的话需要重启服务使用配置aclfile文件或者命令行授权的话无需重启Redis服务但需要及时将权限持久化到磁盘,否则下次重启的时候无法恢复该权限。

官网:https://redis.io/topics/acl

2、配置文件模式

配置ACL的方式有两种,一种是在config文件中直接配置,另一种是在外部aclfile中配置。配置的命令是一样的,但是两种方式只能选择其中一种,我们之前使用requirepass给default用户设置密码,默认就是使用config的方式,执行config rewrite重写配置后会自动在config文件最下面新增一行记录配置default的密码和权限

1、conf文件模式

使用redis.conf文件配置default和其他用户的ACL权限

1
2
3
4
5
6
7
8
9
10
11
# 1.在config文件中配置default用户的密码
requirepass 123456

# 2.在config文件中添加DSL命令配置用户ACL权限
【使用方式在下文】

# 3.在config文件中注释aclfile的路径配置(默认是注释的)
#aclfile /opt/app/redis6/users.acl

# 4.重启redis服务
systemctl restart redis

img

因此我们可以直接在config配置文件中使用上面default用户ACL这行DSL命令设置用户权限,或者我们也可以配置外部aclfile配置权限。

配置aclfile需要先将config中配置的DSL注释或删除,因为Redis不允许两种ACL管理方式同时使用,否则在启动redis的时候会报下面的错误:

1
# Configuring Redis with users defined in redis.conf and at the same setting an ACL file path is invalid. This setup is very likely to lead to configuration errors and security holes, please define either an ACL file or declare users directly in your redis.conf, but not both.

2、外部ACLFILE模式

使用外部aclfile文件配置Default和其他用户的ACL权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1.注释redis.conf中所有已授权的ACL命令,如:
#user default on #8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92 ~* +@all

# 2.在config文件中注释default用户的密码,因为开启aclfile之后,requirepass的密码就失效了:
redis.conf
#requirepass 123456

# users.acl
user default on #8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92 ~* +@all

# 3.在config文件中配置aclfile的路径,然后创建该文件,否则重启redis服务会报错找不到该文件
aclfile /opt/app/redis6/users.acl
touch /opt/app/redis6/users.acl

# 4.在外部aclfile文件中添加DSL命令配置用户ACL权限
【使用方式在下文】

## 5.重启redis服务或使用aclfile load命令加载权限
systemctl restart redis

在redis命令行中执行:
aclfile load

开启aclfile之后不再推荐在redis.conf文件中通过requirepass配置default的密码,因为它不再生效,同时开启aclfile之后也不能使用redis-cli -a xxx登陆,必须使用redis-cli –user xxx –pass yyy来登陆:

img

3、对比conf和aclfile模式

在redis.conf和aclfile模式中配置DSL 官方更推荐使用aclfile,因为如果在redis.conf中配置了权限之后需要重启redis服务才能将配置的权限加载至redis服务中来,但如果使用aclfile模式,可以调用acl load命令将aclfile中配置的ACL权限热加载进环境中,类似于Mysql中的flush privileges。

redis.conf users.acl
配置方式 DSL DSL
加载ACL配置 重启Redis服务 ACL LOAD命令
持久化ACL配置 CONFIG REWRITE命令 ACL SAVE命令

4、命令行模式

1、介绍

上面可以看到,我们在配置文件中配置的ACL权限,需要执行ACL LOAD或者重启Redis服务才能生效,事实上我们可以直接在命令行下配置ACL,在命令行模式下配置的权限无需重启服务即可生效。我们也可以在命令行模式下配置ACL并将其持久化到aclfile或者config文件中(这取决于配置文件中选择的是config模式还是外部aclfile模式),一旦将权限持久化到aclfile或cofig文件中,下次重启就会自动加载该权限,如果忘记持久化,一旦服务宕机或重启,该权限就会丢失。

1
2
3
4
5
# 如果使用config模式,将ACL权限持久化到redis.conf文件中使用下面的命令:
config rewrite

# 如果使用aclfile模式,将ACL权限持久化到users.acl文件中使用下面的命令:
acl save

2、ACL规则

ACL是使用DSL(Domain specific language)定义的,该DSL描述了用户能够执行的操作。该规则始终从上到下,从左到右应用,因为规则的顺序对于理解用户的实际权限很重要。ACL规则可以在redis.conf文件以及users.acl文件中配置DSL,也可以在命令行中通过ACL命令配置。

某些规则只是用于激活或删除标志,或对用户ACL执行给定更改的单个单词。其他规则是字符前缀,它们与命令或类别名称、键模式等连接在一起。

类型 参数 说明
启动用户 on 激活某用户账号
禁用用户 off 禁用某用户账号。注意:已验证的连接仍然可以工作。如果默认用户被标记为off,则新连接将在未进行身份验证的情况下启动,并要求用户使用AUTH选项发送AUTH或HELLO,以便以某种方式进行身份验证。
权限的添加删除 + 将指令添加到用户可以调用的指令列表中
- 从用户可执行指令列表移除指令
+ | subcommand 允许使用已禁用命令的特定子命令
+@ 添加该类别中用户要调用的所有指令,有效类别为@admin、@set、@sortedset…等,通过调用ACL CAT命令查看完整列表。特殊类别@all表示所有命令,包括当前存在于服务器中的命令,以及将来将通过模块加载的命令。
-@ 从用户可调用指令中移除类别
allcommands +@all的别名
nocommand -@all的别名
可操作键的添加或删除 ~ 添加可作为用户可操作的键的模式。例如~*允许所有的键
禁止访问某些Key * resetkeys 使用当前模式覆盖所有允许的模式。如: ~foo:* ~bar:* resetkeys ~objects:* ,客户端只能访问匹配 object:* 模式的 KEY。

为用户配置有效密码

  • ><password>:将此密码添加到用户的有效密码列表中。例如,>mypass将“mypass”添加到有效密码列表中。该命令会清除用户的nopass标记。每个用户可以有任意数量的有效密码。
  • <<password>:从有效密码列表中删除此密码。若该用户的有效密码列表中没有此密码则会返回错误信息。
  • #<hash>:将此SHA-256哈希值添加到用户的有效密码列表中。该哈希值将与为ACL用户输入的密码的哈希值进行比较。允许用户将哈希存储在users.acl文件中,而不是存储明文密码。仅接受SHA-256哈希值,因为密码哈希必须为64个字符且小写的十六进制字符。
  • !<hash>:从有效密码列表中删除该哈希值。当不知道哈希值对应的明文是什么时很有用。
  • nopass:移除该用户已设置的所有密码,并将该用户标记为nopass无密码状态:任何密码都可以登录。resetpass命令可以清除nopass这种状态。
  • resetpass:清空该用户的所有密码列表。而且移除nopass状态。resetpass之后用户没有关联的密码同时也无法使用无密码登录,因此resetpass之后必须添加密码或改为nopass状态才能正常登录。
  • reset:重置用户状态为初始状态。执行以下操作resetpass,resetkeys,off,-@all。

5、ACL HELP

使用下面的命令查看help文档:

1
acl help

img

6、ACL LIST

我们可以使用ACL LIST命令来查看当前活动的ACL,默认情况下,有一个“default”用户:

1
2
127.0.0.1:6379> acl list
1) "user default on nopass ~* +@all"

其中user为关键词,default为用户名,后面的内容为ACL规则描述,on表示活跃的,nopass表示无密码, ~* 表示所有key,+@all表示所有命令。所以上面的命令表示活跃用户default无密码且可以访问所有命令以及所有数据。

7、ACL USERS

返回所有用户名:

1
2
127.0.0.1:6379> acl users
1) "default"

8、ACL WHOAMI

返回当前用户名:

1
2
127.0.0.1:6379> acl whoami
1) "default"

9、ACL CAT

查看命令类别,用于授权:

1
2
ACL CAT:显示所有的命令类别 。
ACL CAT <category>:显示所有指定类别下的所有命令。

img

10、ACL SETUSER

使用下面的命令创建或修改用户属性,username区分大小写

1
2
3
4
5
6
7
8
9
10
# username区分大小写
# 若用户不存在则按默认规则创建用户,若存在则修改用户属性
SETUSER <username> [attribs ...]

# 若用户不存在,则按默认规则创建用户。若用户存在则该命令不做任何操作。
ACL SETUSER <username>

# 若用户不存在,则按默认规则创建用户,并为其增加<rules>。
# 若用户存在则在已有规则上增加 <rules>。
ACL SETUSER <username> <rules>

默认规则下新增的用户处于非活跃状态,且没有密码,同时也没有任何命令和key的权限:

img

例:使用下面的命令新增用户/修改用户的权限:

1
2
3
4
5
6
7
8
#on为活跃状态,密码为wyk123456,允许对所有csdn开头的key使用get和set命令
ACL SETUSER wyk on >wyk123456 ~csdn* +get +set

#为wyk用户新增一个可用密码csdn8888
ACL SETUSER wyk on >csdn8888

#为wyk用户新增list类别下所有命令的权限
ACL SETUSER wyk on +@list

img

img

img

11、ACL GETUSER

使用下面的命令查看用户的ACL权限:

1
2
#查看用户的ACL权限
acl getuser <username>

img

12、ACL DELUSER

删除指定的用户:

1
2
#删除指定的用户
acl deluser <username>

img

13、ACL SAVE

前面提到过,我们可以使用acl save命令将当前服务器中的ACL权限持久化到aclfile中,如果没持久化就关闭redis服务,那些ACL权限就会丢失,因此我们每次授权之后一定要记得ACL SAVE将ACL权限持久化到aclfile中:

1
2
3
4
5
# 将acl权限持久化到磁盘的aclfile中
acl save

# 如果使用redis.conf配置ACL,则使用config rewrite命令将ACL持久化到redis.conf中
config rewrite

img

img

14、ACL LOAD

我们也可以直接在aclfile中修改或新增ACL权限,修改之后不会立刻生效,我们可以在redis命令行中执行acl load将该aclfile中的权限加载至redis服务中:

1
2
# 将aclfile中的权限加载至redis服务中
acl load

img

15、ACL GENPASS

随机返回sha256密码,我们可以直接使用该密文配置ACL密码:

1
2
3
4
5
6
# 随机返回一个256bit的32字节的伪随机字符串,并将其转换为64字节的字母+数字组合字符串
acl genpass

# 可指定位数
acl genpass 32
acl genpass 64

img

16、ACL LOG

查看ACL安全日志:

1
acl log

17、AUTH

使用auth命令切换用户:

1
AUTH <username> <password>

img

18、总结

由于Redis是高性能的数据库,正常情况下每秒可以接收百万级别的请求,因此我们的用户密码一定要是非常复杂的组合,否则很容易就会被暴力跑字典给破解了,不管怎么说,这次Redis6版本带来的新特性ACL权限控制也是解决了我们很大的痛点,终于可以权限隔离了!

9、Redis6新特性之RESP3与客户端缓存(Client side caching)

Redis6引入新的RESP3协议,并以此为基础加入了客户端缓存的新特性,在此特性下,大大提高了应用程序的响应速度,并降低了数据库的压力。

1、什么是客户端缓存

1、介绍

客户端缓存是一种用于创建高性能服务的技术,在此技术下,应用程序端将数据库中的数据缓存在应用端的内存中,当应用程序访问数据时直接从本机内存中读取,而无需连接数据库端,减少了网络IO,提升了应用程序的响应速度,同时也减少了数据库端的压力。

官网:https://redis.io/topics/client-side-caching

Why RESP3:http://antirez.com/news/125

没有客户端缓存:

img

应用端先查询Redis端,如果没有Redis缓存则到源数据库端查询,如果有则直接从Redis端查询数据,更新数据时直接更新MySQL端并同步至Redis内;

有客户端缓存:

img

应用端先查询本地缓存如Guava、Caffeine,若没有本地缓存则访问Redis缓存,如果Redis缓存中也没有则查询源数据库;

2、客户端缓存的优点

  • 降低了客户端的数据延迟,提升客户端的响应速度;
  • 数据库端接收的查询减少,降低了数据库端的压力,因此在相同的数据集下可以使用更少的节点提供服务;

疑问:

为了实现客户端缓存,我们面临这样的问题,当进程中缓存了数据,而数据库端数据发生变更,该如何通知到进程,避免客户端显示失效的数据呢?(缓存一致性)

在Redis中可以使用发布订阅机制,向客户端发布数据失效的通知,但该模式下即使某些客户端中没有包含过期数据也会向所有客户端发送无效的消息,非常影响数据库的性能

在之前的版本中,客户端缓存采用缓存槽(caching slot)**的方式记录每个客户端内的key是否发生变化以及时同步,最新版中已弃用该方式,而是采用记录key的名称或前缀**。

2、什么是RESP3

RESP 全称 REdisSerializationProtocol,是 Redis 服务端与客户端之间通信的协议。在Reds6之前的版本,使用的是RESP2协议,数据都是字符串数组的形式返回给客户端,不管是 list 还是 sorted set。因此客户端需要自行去根据类型进行解析,这样会增加了客户端实现的复杂性。

为了照顾老用户,Redis6在兼容 RESP2 的基础上,开始支持 RESP3,但未来会全面切换到RESP3之上。今天的客户端缓存在基于RESP3才能有更好的实现,可以在同一个连接中运行数据的查询和接收失效消息。而目前在RESP2上实现的客户端缓存,需要两个客户端连接以转发重定向的形式实现。

1
2
3
4
5
# 使用RESP2协议
HELLO 2

# 使用RESP3协议
HELLO 3

img

3、客户端缓存的实现方式

Redis客户端缓存被称为Tracking,在RESP3协议下,有两种模式:

  • 默认模式:服务器记录客户端访问了哪些key,当其中的key发生变更时给客户端发送失效信息,消耗服务器端内存;
  • 广播模式:客户端订阅访问过的key的前缀,当符合模式的key发生变更就会被通知(即使变更的key没有被客户端缓存),服务器端不记录客户端访问的key,因此不会消耗服务器端的内存;

4、默认模式

1、原理

服务器端会记录访问key的客户端列表并维护一个表,这个表被称为==失效表==(Invalidation Table),如果插入一个新的key,服务器端会给客户端发送失效信息并从客户端踢除该key,避免提供过时数据。

在失效表中不会记录key和客户端内对应指针的映射关系,只会记录key的指针和各客户端ID(每个Redis客户端都有一个唯一ID)的映射关系,当发送完失效信息后,客户端剔除key服务端从失效表中删除key的指针和客户端ID的映射关系

在失效表中key的命名空间只有一个,即是说,在db0~db15中相同的key名,在失效表中会记录在同一个命名空间内,即使客户端缓存的是db0内的key,如果db1内的同名key被更新,也会通知客户端剔除db0内的同名key。

客户端缓存的操作就是对key的内存地址进行操作

  1. 当开启客户端缓存的客户端从Redis获取数据时,Redis服务端会调用 enableTracking 方法在上面的失效表中记录key和客户端ID的映射关系;
  2. 若key被修改,则Redis服务端会调用 trackingInvalidateKey 函数根据该key被缓存的客户端列表ID调用 sendTrackingMessage 函数向它们发送失效消息。(发送失效消息前会检查客户端的Client_TrackingNOLOOP状态)
  3. 服务端发送完失效消息后会从失效表中将该key与客户端ID的映射关系删除;
  4. 由于客户端可能会在开启之后关闭了缓存功能,在失效表中删除key和该客户端ID之间的映射关系比较消耗性能,因此服务端采用懒删除的方式只是将该客户端的Client_Tracking相关标志位删除

img

2、应用

上面提到我们可以使用HELLO命令切换RESP3协议,在此协议下我们使用tracking命令开启track追踪,此时服务端会记录客户端在连接的生命周期内的只读的key,当客户端开启track追踪后,key的数据会被缓存在客户端内存中:

1
2
3
4
5
6
7
8
# 开启RESP3协议
HELLO 3

# 开启tracking 客户端缓存
client tracking on

# 关闭tracking 客户端缓存
client tracking off

为了演示失效消息的通知,这里使用telnet测试客户端缓存,然后在另一个redis-cli对key做操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 使用telnet连接客户端
telnet wykd 6379

# auth命令登录服务器(如果没有密码可以忽略)
auth default wyk123456

# 开启RESP3
hello 3

# 开启客户端缓存 tracking
client tracking on

# 查询一个key 同时该key会被缓存
get name

# 在另一个redis客户端中 修改/删除/过期/淘汰 该key
set name new_values

# 在telnet窗口会收到key失效的消息如下:
get name #客户端缓存key
$3
wyk

>2 #失效消息
$10
invalidate
*1
$4
name

# 关闭客户端缓存 tracking,关闭后不会再收到key的失效消息
client tracking off

img

当开启了tracking后,客户端缓存的key如果在别处被修改为与原值一样,也会收到失效消息

当客户端缓存失效后,该key再被修改时,客户端不会再收到消息,也就是再查询该key之后 才会在客户端缓存key的值;

当客户端缓存的key因过期策略内存淘汰策略被驱逐时,服务端也会发送失效消息给开启了tracking的客户端:

img

当开启了tracking的客户端获取的key不存在时,如果在另一个客户端新增/修改了该key,那个tracking的客户端也会收到失效消息,可见如果key不存在也会在客户端缓存中缓存空值,这种结果因人而异,个人认为这样不太好:

  1. 一是客户端会徒增大量的无用缓存(空值)
  2. 二是服务端的失效表会维护更多的key->clientID的映射关系。

img

5、广播模式

1、原理

另一个客户端缓存的实现方式是广播模式(broadcasting)**,广播不会消耗服务端的内存,而是向各客户端发送更多的失效消息。广播模式与默认模式类似,不同的是广播模式下维护的是前缀表,在前缀表中存储客户端订阅的key前缀与客户端ID之间的映射关系。**

在这种模式下,有以下的主要行为:

  1. 客户端使用 BCAST 选项开启客户端缓存的广播模式,并使用 PREFIX 指定一个或多个前缀。如果不指定前缀则默认客户端接收所有的key的失效消息,如果指定则只会接收匹配该前缀的key的失效消息;
  2. 在广播模式下,服务端维护的不是失效表,而是前缀表(Prefix Table),每个前缀映射一些客户端ID
  3. 每次修改跟任意前缀匹配的键时,所有订阅该前缀的客户端都将收到失效消息
  4. 服务端的CPU消耗与订阅的key前缀数量成正比,订阅的key前缀数量越多服务器端压力越大
  5. 服务器可以为订阅特定前缀的客户端创建单个回复,并向所有的客户端发送相同的回复来进行优化,有助于降低CPU使用率。

img

2、应用

同样,在广播模式下也需要开启RESP3协议,这里我们仍然使用刚才的telnet会话进行演示。

使用下面的命令开启广播模式的客户端缓存,上面提到广播模式下服务端维护一个前缀表,记录key的前缀和客户端id的映射关系,因此我们也可以在客户端指定需要接收失效消息的key前缀:

1
2
3
4
5
6
7
8
9
# telnet访问redis客户端(略)
# 开启RESP3
hello 3

# 开启广播模式的客户端缓存tracking,默认会收到所有的key的失效信息
client tracking on bcast

# 开启广播模式的客户端缓存tracking,只接受指定前缀'wyk'的key的失效信息
client tracking on bcast prefix wyk

img

广播模式下,只要符合客户端设置的key前缀的key发生新增、修改、删除、过期、淘汰等动作,即使该key没有被该客户端缓存,也会收到key的失效消息

6、重定向模式

为了兼容RESP2协议,在Redis6中客户端缓存可以以重定向(Redirect)的方式实现,不再使用 RESP3 原生支持的PUSH消息,而是将消息通过 Pub/Sub 通知给另外一个客户端连接

img

1
2
3
4
5
6
7
8
# 查看客户端id
client id

# 用于接收失效消息的客户端订阅频道
subscribe _redis_:invalidate

# 客户端开启Tracking客户端缓存 并指定需要接收失效消息的客户端ID
client tracking on bcast redirect receive_client_id

img

7、OPTIN 和 OPTOUT

在默认模式或重定向模式下,我们可以有选择的对需要的key进行缓存,而由于广播模式是匹配key前缀,因此不能使用此命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# RESP3 默认模式
# 切换RESP3协议
hello 3

# 开启客户端缓存optin选项
client tracking on optin

# 此命令后面第一个只读key会被缓存
client caching yes


# RESP2 重定向模式
hello 2

# 开启客户端缓存optin选项,1234是接收失效消息的客户端id
client tracking on REDIRECT 1234 OPTIN

# 此命令后面第一个只读key会被缓存
client caching yes
  • OPTIN:只有执行client caching yes之后的第一个key才会被缓存;
  • OPTOUT:与OPTIN相反,执行client caching no之后的第一个只读key不会被缓存;

img

img

注意:在redis6.0.3版本中outin和optout选项时灵时不灵,可能还有BUG;

8、NOLOOP选项

我们的客户端修改自己已缓存的key的时候也会收到这个key的过期信息,事实上这个客户端是不需要收到该消息的,这造成了浪费,因此我们可以使用NOLOOP选项将该客户端设置为:本客户端修改的key不会收到相关的失效信息。

1
2
# 开启客户端缓存的NOLOOP选项
client tracking on noloop

开启noloop选项的客户端,如果在该客户端上修改它已经缓存的key,自己不会收到该key的失效消息:

img

没开启noloop选项的客户端,如果在该客户端上修改它已经缓存的key,自己也会收到该key的失效消息:

img

9、失效表key上限

可以使用 tracking_table_max_keys 参数修改服务端失效表内记录的缓存的key的数量,当失效表内记录的缓存key达到配置的数量时会随机从失效表内移除缓存:

1
2
3
4
5
# 查询最大缓存的数量
config get tracking-table-max-keys

# 设置最大缓存数量为300
config set tracking-table-max-keys 300

img

10、Redis6新特性之集群代理(Cluster Proxy)

在之前的文章中介绍了Redis6的集群搭建和原理,我们可以使用dummy和smart客户端连接集群,本篇介绍Redis6新增的一个功能:集群代理。客户端不需要知道集群中的具体节点个数和主从身份,可以直接通过代理访问集群,对于客户端来说通过集群代理访问的集群就和单机的Redis一样,因此也能解决很多集群的使用限制。

1、介绍

在Redis6的release note中可以看到新功能中的ACL,RESP3,客户端缓存我们在前面的文章中已经介绍过,本篇就看一下集群代理。集群代理与Redis在Github上是不同的项目,地址如下:

Github:https://github.com/RedisLabs/redis-cluster-proxy

集群代理(Redis Cluster Proxy): 将集群抽象为单实例,客户端不需要知道集群中的具体节点个数和主从身份,通过代理访问集群,就像访问单机Redis一样。同时集群代理也能解决在集群模式下multiple操作的限制及跨slot操作限制(如mget,mset…)。

img

Redis集群代理的特点:

  • 自动化路由:每个查询被自动路由到集群的正确节点;
  • 多线程:多路复用通信模型,每个线程都有自己的集群连接;
  • 顺序性:在多路复用上下文中,保证查询的执行和应答顺序;
  • 无感知更新集群信息:当请求/重定向错误时会自动更新集群信息,客户端提交的查询会在集群信息更新完成后重新执行,对于客户端来说这一切是无感的,客户端不会收到请求/重定向的错误信息,而是直接收到查询的结果;
  • 跨槽/节点查询:支持跨slot或node的mutiple操作key,如mget,mset,del等。但由于mset,del会破坏原子性,因此该配置默认关闭;
  • ACL:支持连接开启了ACL的Redis集群;
  • DBSIZE:对于没有指定节点的命令,将会合并所有的信息的总和并返回;

img

2、安装

1、下载解压

从github上下载解压源码(2020-06-30:目前最新版是unstable版本)

1
2
3
4
5
# git命令
git clone https://github.com/artix75/redis-cluster-proxy

# 手动下载zip解压
unzip redis-cluster-proxy-unstable.zip

2、安装gcc4.9+版本

在之前安装Redis6的文章中有介绍,此处略过安装gcc9.1:

1
2
3
4
5
# 开启gcc9.1
scl enable devtoolset-9 bash

# 查看gcc版本
gcc -v

img

3、编译

执行下面的命令编译源码,出现下图表示安装成功:

1
2
3
4
5
6
# 进入目录并编译
cd redis-cluster-proxy-unstable
make

# 如果编译出错之后再编译可以先执行命令删除之前的编译文件
make distclean

如果遇到错误 unknown type name ‘_Atomic’ ,请检查gcc版本重新安装;

img

4、安装

编译成功后使用下面的命令安装Redis集群代理服务,出现下图表示安装成功:

1
2
# 安装Redis集群代理,可指定安装目录
make install PREFIX=/opt/app/redis-cluster-proxy

img

5、使用

1、配置启动

从源码中将配置文件copy到安装目录:

1
cp /home/wyk/redis-cluster-proxy-unstable/proxy.conf /opt/app/redis-cluster-proxy/

修改配置文件:vim /opt/app/redis-cluster-proxy/proxy.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#配置Redis集群,这里我使用前几篇文章中配置的Redis6集群,三主三从
cluster 127.0.0.1:6381 #主1
cluster 127.0.0.1:6382 #主2
cluster 127.0.0.1:6383 #主3
cluster 127.0.0.1:6391 #从1
cluster 127.0.0.1:6392 #从2
cluster 127.0.0.1:6393 #从3

#默认端口
port 7777

#线程数
threads 8

#后台运行
daemonize yes

#日志文件
logfile "/opt/app/redis-cluster-proxy/redis-cluster-proxy.log"

#允许跨slot查询
enable-cross-slot yes

#最大客户端连接数
max-clients 10000

#ACL用户密码(也可以在启动服务时指定)
auth-user myuser #ACL用户
auth mypassw #ACL密码

#连接池
connections-pool-size 10
connections-pool-min-size 10
connections-pool-spawn-every 50
connections-pool-spawn-rate 50

创建日志文件并使用下面的命令指定配置文件启动集群代理:

1
2
3
4
5
# 创建日志文件
touch /opt/app/redis-cluster-proxy/redis-cluster-proxy.log

# 启动Redis集群代理服务
/opt/app/redis-cluster-proxy/bin/redis-cluster-proxy -c /opt/app/redis-cluster-proxy/proxy.conf

img

连接集群代理客户端

Redis集群代理服务监听7777端口,我们可以使用Redis命令行指定7777端口启动集群代理客户端:

1
2
# 连接Redis集群代理客户端
/opt/app/redis6/bin/redis-cli -p 7777

img

2、跨节点slot操作

上面提到在集群代理中,会将集群抽象成一个Redis实例,对用户来说跨slot/node操作是无感的,而在默认集群中会重定向到对应slot所在的节点进行操作。

默认集群模式:

在之前的Redis集群文章中演示了在dummy客户端中操作集群内的key时会重定向到该key存储的slot所在的节点:

1
2
3
4
5
# 使用-c进入集群命令行模式
redis-cli -c -p 6381

# 使用命令查看key所在的槽
cluster keyslot key1

img

img

集群代理模式:

在集群代理模式下,可以跨slot甚至跨节点操作key,而在集群模式下链接客户端是做不到的。下图演示了如果在集群代理中使用mset和mget跨slot跨node设置或查询key,对于用户来说仿佛是在使用一个单实例的Redis:

img

6、故障转移

手动的使集群中一个主节点宕机,测试集群代理能否感知到集群的故障转移:

1
2
3
# 在6381主节点执行命令,手动的让其宕机
# 命令执行一个非法的内存访问从而让 Redis 崩溃,仅在开发时用于 BUG 调试,执行后需要重启服务
debug segfault

情况一、主节点6381宕机,6391节点升级为主节点,集群恢复正常,但6381节点还没启动,此时集群代理无法使用,需要启动6381节点之后集群代理才能恢复使用:

img

img

情况二、手动将6381主节点宕机,当从节点6391升级为主节点后,重启6381节点作为6391的从节点,此时集群的主从机器全部正常启动,查询集群代理,不会收到影响:

img

3、结尾

目前在Github上最新的版本仍是unstable版,毕竟是新功能,还是有很多BUG的,像集群的故障转移在集群代理中就没有做的很好,其次就是如果集群代理服务本身没有解决单点故障(可以尝试配合HAProxy等代理服务做负载均衡)。

官方最后声明中也提到【当前处于α版本,不推荐在生产环境使用]】:

This project is currently alpha code that is indented to be evaluated by the community in order to get suggestions and contributions. We discourage its usage in any production environment.

但不可否认的是集群代理给redis集群提供了轻量的代理层,也解决了很多在集群模式中的使用限制,未来的潜力还很大,让我们拭目以待吧!

11、Redis6新特性之IO多线程

终于,Redis的多线程版本横空出世,大大提高了并发,本篇就带大家来看看什么是IO多线程,和我们理解的多线程有什么区别,与Memcached的多线程又有什么区别。

1、介绍

作为Redis6版本中的其中一大新特性,IO多线程大大提升了Redis的并发性能。该功能也是在社区内被反复提起,而之前Antirez在自己的博客中也曾经做过简单的介绍:http://antirez.com/news/126

2、为什么Redis6.0之前是单线程模型

首先我们要明确一个共识,我们通常所说的Redis单线程是指获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这个主线程就是我们平时说的”单线程”**,而其他的清理脏数据、无用连接的释放、LRU淘汰策略等等也是有其他线程在处理的,因此其实在Redis6之前的Redis本质上也是多线程的。**

为什么这些操作要放在同一个主线程中,官方给出的解释:传送门

  • 通常瓶颈不在 CPU,而是在内存和网络IO;
  • 多线程会带来线程不安全的情况;
  • 多线程可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗;
  • 单线程降低了Redis内部实现复杂度;
  • hash的惰性rehash,lpush等线程不安全的命令可以无锁执行;

3、什么是IO多线程

既然上面说单线程那么好,为什么Redis6.0又要引入多线程呢?

Redis 抽象了一套 AE 事件模型,将 IO 事件和时间事件融入一起,同时借助多路复用机制(linux上用epoll) 的回调特性,使得 IO 读写都是非阻塞的,实现高性能的网络处理能力。加上 Redis 基于内存的数据处理,这就是 “单线程,但却高性能” 的核心原因。

但 IO 数据的读写依然是阻塞的,这也是 Redis 目前的主要性能瓶颈之一,特别是在数据吞吐量特别大的时候,具体情况如下:

img

上图的下半部分,当 socket 中有数据时,Redis 会通过系统调用将数据从内核态拷贝到用户态,供 Redis 解析用。这个拷贝过程是阻塞的,术语称作 “同步 IO”,数据量越大拷贝的延迟越高,时间消耗也越大,糟糕的是这些操作都是单线程处理的。(写 reponse 时也是一样)

这是 Redis 目前的瓶颈之一,Redis6.0 引入的 “多线程” 机制就是对于该瓶颈的优化。核心思路是,将主线程的 IO 读写任务拆分出来给一组独立的线程执行,使得多个 socket 的读写可以并行化。

与 Memcached 从 IO 处理到数据访问多线程的实现模式有些差异。Redis 的IO多线程只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。之所以这么设计是不想 Redis 因为多线程而变得复杂,需要去控制 key、lua、事务,LPUSH/LPOP 等等的并发问题。

4、开启IO多线程

默认情况下,Redis多线程是禁用的,我们可以在配置文件选择开启:vim redis.conf

1
2
3
4
5
# 开启IO多线程
io-threads-do-reads yes

# 配置线程数量,如果设为1就是主线程模式。
io-threads 4

官方建议:至少4核的机器才开启IO多线程,并且除非真的遇到了性能瓶颈,否则不建议开启此配置 ,且配置的线程数少于机器总线程数,如果有4核建议开启2,3个线程,如果有8核建议开6线程。 线程并不是越多越好,多于8个线程意义不大。

5、性能对比

因资源有限,我手边的机器渣渣配置如下,开启3个线程对比单线程:

配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[root@BD-T-uatredis9 ~]# free -h
total used free shared buff/cache available
Mem: 15G 1.0G 13G 64M 1.2G 14G
Swap: 4.0G 0B 4.0G
[root@BD-T-uatredis9 ~]# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: 1
Core(s) per socket: 1
Socket(s): 4
NUMA node(s): 1
Vendor ID: GenuineIntel
CPU family: 6
Model: 79
Model name: Intel(R) Xeon(R) CPU E7-4809 v4 @ 2.10GHz
Stepping: 1
CPU MHz: 2094.952
BogoMIPS: 4189.90
Hypervisor vendor: VMware
Virtualization type: full
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
L3 cache: 20480K
NUMA node0 CPU(s): 0-3

测试命令:

使用redis-benchmark进行压测,这里模拟在4核4线程的机器上分别测试3线程和单线程在100W请求,数据大小在128b,512b,1024b,200个客户端,执行SET和GET的QPS性能对比

1
2
3
4
5
6
7
8
9
10
11
12
13
#三线程
./redis-benchmark -h localhost -p 6380 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 --threads 3 -d 128 -c 200 -q

./redis-benchmark -h localhost -p 6380 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 --threads 3 -d 512 -c 200 -q

./redis-benchmark -h localhost -p 6380 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 --threads 3 -d 1024 -c 200 -q

#单线程
./redis-benchmark -h localhost -p 6381 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 -d 128 -c 200 -q

./redis-benchmark -h localhost -p 6381 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 -d 512 -c 200 -q

./redis-benchmark -h localhost -p 6381 --user default -a wyk123456 -t set,get -n 1000000 -r 1000000 -d 1024 -c 200 -q

结果:

可能是我机器太渣了,3线程比单线程的QPS提升有120%~140%,网友测试的在4线程下QPS提升了100%。

img

网友的测试结果:

1
2
Redis Server: 阿里云 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 内存,主机型号 ecs.ic5.2xlarge
Redis Benchmark Client: 阿里云 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 内存,主机型号 ecs.ic5.2xlarge

img

img

注意,数据仅供验证参考,不能作为线上指标:

  • 本测试只是使用早期的 unstble 分支的性能,不排除稳定版的性能会更好。
  • 本测试并没有针对严谨的延时控制和不同并发的场景进行压测。

6、源码解析

刚才提到IO多线程只是在网络数据的读写上是多线程了,具体流程如下:

img

流程:

  1. 主线程获取 socket 放入等待列表
  2. 将 socket 分配给各个 IO 线程(并不会等列表满)
  3. 主线程阻塞等待 IO 线程读取 socket 完毕
  4. 主线程以单线程执行命令 (如果命令没有接收完毕,会等 IO 下次继续)
  5. 主线程阻塞等待 IO 线程将数据回写 socket 完毕(一次没写完,会等下次再写)
  6. 解除绑定,清空等待队列
  • IO 线程要么同时在读 socket,要么同时在写,不会同时读或写;
  • IO 线程只负责读写 socket 解析命令,不负责执行命令,由主线程串行执行命令;
  • IO 线程数可配置,默认为 1;
  • 上面的过程是完全无锁的,因为在 IO 线程处理的时主线程会等待全部的 IO 线程完成,所以不会出现 data race 的场景。

源码

redis-server 逻辑首先执行 initThreadedIO()函数对 线程进行初始化,当然,也包括 根据配置 server.io_threads_num 控制线程个数,其中主线程的处理逻辑为 IOThreadMain() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* networking.c: line 2666 */
void *IOThreadMain(void *myid) {
/* The ID is the thread number (from 0 to server.iothreads_num-1), and is used by the thread to just manipulate a single sub-array of clients. */
// 线程 ID,跟普通线程池的操作方式一样,都是通过 线程ID 进行操作
long id = (unsigned long)myid;
while(1) {
/* Wait for start */
// 这里的等待操作比较特殊,没有使用简单的 sleep,避免了 sleep 时间设置不当可能导致糟糕的性能,但是也有个问题就是频繁 loop 可能一定程度上造成 cpu 占用较长
for (int j = 0; j < 1000000; j++) {
if (io_threads_pending[id] != 0) break;
}
/* Give the main thread a chance to stop this thread. */
if (io_threads_pending[id] == 0) {
pthread_mutex_lock(&io_threads_mutex[id]);
pthread_mutex_unlock(&io_threads_mutex[id]);
continue;
}
serverAssert(io_threads_pending[id] != 0);
// debug 模式
if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id]));
/* Process: note that the main thread will never touch our list
* before we drop the pending count to 0. */
// 根据线程 id 以及待分配列表进行 任务分配
listIter li;
listNode *ln;
listRewind(io_threads_list[id],&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
// 判断读写类型
if (io_threads_op == IO_THREADS_OP_WRITE) {
writeToClient(c,0);
} else if (io_threads_op == IO_THREADS_OP_READ) {
// 这里需要注意重复调用了 readQueryFromClient,不过不用担心,有 CLIENT_PENDING_READ 标识可以进行识别
readQueryFromClient(c->conn);
} else {
serverPanic("io_threads_op value is unknown");
}
}
listEmpty(io_threads_list[id]);
io_threads_pending[id] = 0;
if (tio_debug) printf("[%ld] Done\n", id);
}
}

handleClientsWithPendingReadsUsingThreads() 待处理任务分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/* networking.c: line 2871 */
/* When threaded I/O is also enabled for the reading + parsing side, the readable handler will just put normal clients into a queue of clients to process (instead of serving them synchronously). This function runs the queue using the I/O threads, and process them in order to accumulate the reads in the buffers, and also parse the first command available rendering it in the client structures. */
int handleClientsWithPendingReadsUsingThreads(void) {
// 是否开启 线程读
if (!io_threads_active || !server.io_threads_do_reads) return 0;
int processed = listLength(server.clients_pending_read);
if (processed == 0) return 0;
if (tio_debug) printf("%d TOTAL READ pending clients\n", processed);
/* Distribute the clients across N different lists. */
// 将待处理任务进行分配,分配方式为 RR (round robin) 即基于任务到达时间片进行分配
listIter li;
listNode *ln;
listRewind(server.clients_pending_read,&li);
int item_id = 0;
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
int target_id = item_id % server.io_threads_num;
listAddNodeTail(io_threads_list[target_id],c);
item_id++;
}

/* Give the start condition to the waiting threads, by setting the start condition atomic var. */
// 设定任务个数参数
io_threads_op = IO_THREADS_OP_READ;
for (int j = 0; j < server.io_threads_num; j++) {
int count = listLength(io_threads_list[j]);
io_threads_pending[j] = count;
}
/* Wait for all threads to end their work. */
// 等待所有线程任务都处理完毕
while(1) {
unsigned long pending = 0;
for (int j = 0; j < server.io_threads_num; j++)
pending += io_threads_pending[j];
if (pending == 0) break;
}
if (tio_debug) printf("I/O READ All threads finshed\n");
/* Run the list of clients again to process the new buffers. */
// 继续运行,等待新的处理任务
listRewind(server.clients_pending_read,&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
c->flags &= ~CLIENT_PENDING_READ;
if (c->flags & CLIENT_PENDING_COMMAND) {
c->flags &= ~ CLIENT_PENDING_COMMAND;
processCommandAndResetClient(c);
}
processInputBufferAndReplicate(c);
}
listEmpty(server.clients_pending_read);
return processed;
}

readQueryFromClient() 函数

1
2
3
4
5
6
7
8
9
10
11
/* networking.c: line 1791 */
void readQueryFromClient(connection *conn) {
client *c = connGetPrivateData(conn);
int nread, readlen;
size_t qblen;
/* Check if we want to read from the client later when exiting from the event loop. This is the case if threaded I/O is enabled. */
// 加入多线程模型已经启用
if (postponeClientRead(c)) return;
// 如果没有启用多线程模型,则走下面继续处理读逻辑
// ....还有后续老逻辑
}

函数 postponeClientRead() 将任务放入处理队列,而根据上面 IOThreadMain()handleClientsWithPendingReadsUsingThreads() 的任务处理逻辑进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* networking.c: line 2852 */
int postponeClientRead(client *c) {
// 如果启用多线程模型,并且判断全局配置中是否支持多线程读
if (io_threads_active &&
server.io_threads_do_reads &&
// 这里有个点需要注意,如果是 master-slave 同步也有可能被认为是普通 读任务,所以需要标识
!(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ)))
{
c->flags |= CLIENT_PENDING_READ;
// 将任务放入处理队列
listAddNodeHead(server.clients_pending_read,c);
return 1;
} else {
return 0;
}
}

7、对比Memcached

前些年memcached 是各大互联网公司常用的缓存方案,因此redis 和 memcached 的区别基本成了面试官缓存方面必问的面试题,最近几年memcached用的少了,基本都是 redis。不过随着Redis6.0加入了多线程特性,类似的问题可能还会出现,接下来我们只针对多线程模型来简单比较一下它们。

首先看一下Memcached的线程模型:

img

如上图所示:Memcached 服务器采用 master-woker 模式进行工作,服务端采用 socket 与客户端通讯。主线程、工作线程 采用 pipe管道进行通讯。主线程采用 libevent 监听 listen、accept 的读事件,事件响应后将连接信息的数据结构封装起来,根据算法选择合适的工作线程,将连接任务携带连接信息分发出去,相应的线程利用连接描述符建立与客户端的socket连接 并进行后续的存取数据操作。

Redis6.0与Memcached多线程模型对比:

  • 相同点:都采用了 master线程-worker 线程的模型
  • 不同点:Memcached 执行主逻辑也是在 worker 线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。而 Redis 把处理逻辑交还给 master 线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题。

8、结尾

大家都会拿Redis和memcached对比,但Redis不是memcached,它只是做到like memcached的多线程,而不是跟memcached一样的完全隔离的多线程模型。Redis中因为有lua脚本,事务,Lpush等等复杂性,需要考虑的问题很多,不管怎么样,最新版的Redis6带给我们的IO多线程着实是个惊喜,互联网大厂们应该很快就会纷纷上线此功能了!


资料来源

黑马程序员Redis入门到精通,Java企业级解决方案必看

【尚硅谷】2021 最新 Redis 6 入门到精通 超详细 教程

Redis系列

Redis6使用指导(完整版)

如何保证 Redis 缓存与数据库双写一致性?

[TOC]

Mysql 高级篇

1、索引(Index)

1、索引概述

MySQL官方对索引的定义为:索引(index)是帮助MySQL高效获取数据的数据结构(有序)。

在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用(指向)数据, 这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。

如下面的==示意图==所示:

1555902055367

相关说明:

  • 左边是数据表,一共有两列七条记录,最左边的是数据记录的物理地址(注意逻辑上相邻的记录在磁盘上也并不是一定物理相邻的)。
  • 为了加快Col2的查找,可以维护一个右边所示的二叉查找树,每个节点分别包含索引键值和一个指向对应数据记录物理地址的指针,这样就可以运用二叉查找快速获取到相应数据。

一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储在磁盘上索引是数据库中用来提高性能的最常用的工具。

2、索引优势劣势

索引的优势:

  1. 类似于书籍的目录索引,提高数据检索的效率,降低数据库的IO成本
  2. 通过索引列对数据进行排序,降低数据排序的成本,降低CPU的消耗

索引的劣势:

  1. 实际上索引也是一张表,该表中保存了主键与索引字段,并指向实体类的记录,所以索引列也是要占用空间的。
  2. 虽然索引大大提高了查询效率,同时却也降低更新表的速度,如对表进行INSERT、UPDATE、DELETE。因为更新表时,MySQL 不仅要保存数据,还要保存一下索引文件每次更新添加了索引列的字段,都会调整因为更新所带来的键值变化后的索引信息

3、索引结构

索引是在MySQL的==存储引擎层==中实现的,而不是在服务器层实现的。所以每种存储引擎的索引都不一定完全相同,也不是所有的存储引擎都支持所有的索引类型的。

MySQL目前提供了以下4种索引:

  • BTREE 索引 :最常见的索引类型,大部分索引都支持 B 树索引。
  • HASH 索引:只有Memory引擎支持 , 使用场景简单 。
  • R-tree 索引(空间索引):空间索引是MyISAM引擎的一个特殊索引类型,主要用于地理空间数据类型,通常使用较少,不做特别介绍。
  • Full-text (全文索引) :全文索引也是MyISAM的一个特殊索引类型,主要用于全文索引,InnoDB从Mysql5.6版本开始支持全文索引。
MyISAM、InnoDB、Memory三种存储引擎对各种索引类型的支持
索引 InnoDB引擎 MyISAM引擎 Memory引擎
BTREE索引 支持 支持 支持
HASH 索引 不支持 不支持 支持
R-tree 索引 不支持 支持 不支持
Full-text 5.6版本之后支持 支持 不支持
  • 我们平常所说的索引,如果没有特别指明,都是指B+树(多路搜索树,并不一定是二叉的)结构组织的索引。
  • 其中==聚集索引==、==次要索引==、==覆盖索引==、==复合索引==、==前缀索引==、==唯一索引==默认都是使用 B+tree 索引,统称为 索引。

注意:关于 InnoDB引擎 支不支持 HASH 索引问题

  • InnoDB用户无法手动创建哈希索引,这一层上说,InnoDB确实不支持哈希索引
  • InnoDB会自调优(self-tuning),如果判定建立自适应哈希索引(Adaptive Hash Index, AHI),能够提升查询效率,InnoDB自己会建立相关哈希索引,这一层上说,InnoDB又是支持哈希索引的。
1、InnoDB的自调优

那什么是自适应哈希索引(Adaptive Hash Index, AHI)呢?原理又是怎样的呢? 咱们先从一个例子开始。

不妨设有InnoDB数据表:t(id PK, name KEY, sex, flag)

id是主键,name建了普通索引。

假设表中有四条记录:

  • 1, shenjian, m, A
  • 3, zhangsan, m, A
  • 5, lisi, m, A
  • 9, wangwu, f, B

img

如上图,通过前序知识,容易知道InnoDB在主键id上会建立聚集索引(Clustered Index),叶子存储记录本身,在name上会建立普通索引(Secondary Index),叶子存储主键值。

发起主键id查询时,能够通过聚集索引,直接定位到行记录。

img

1
select * from t where name='ls'; 

发起普通索引查询时:

  • 会先从普通索引查询出主键(上图右边);
  • 再由主键,从聚集索引上二次遍历定位到记录(上图左边)。

不管聚集索引还是普通索引,记录定位的寻路路径(Search Path)都很长。

在MySQL运行的过程中,如果InnoDB发现,有很多SQL存在这类很长的寻路,并且有很多SQL会命中相同的页面(page),InnoDB会在自己的内存缓冲区(Buffer)里,开辟一块区域,建立自适应哈希所有AHI,以加速查询。

img

从这个层面上来说,InnoDB的自使用哈希索引,更像“索引的索引”,毕竟其目的是为了加速索引寻路。

既然是哈希,key是什么,value是什么?

  • ==key是索引键值(或者键值前缀)。==
  • ==value是索引记录页面位置。==

为啥叫“自适应(adaptive)”哈希索引?

系统自己判断“应该可以加速查询”而建立的,不需要用户手动建立,故称“自适应”。

系统会不会判断失误,是不是一定能加速?

不是一定能加速,有时候会误判。 当业务场景为下面几种情况时:

  • 很多单行记录查询(例如passport,用户中心等业务)
  • 索引范围查询(此时AHI可以快速定位首行记录)
  • 所有记录内存能放得下

AHI往往是有效的。

任何脱离业务的技术方案,都是耍流氓。

当业务有大量like或者join,AHI的维护反而可能成为负担,降低系统效率,此时可以手动关闭AHI功能。

2、BTREE(B树)结构

BTree又叫多路平衡搜索树,一颗m叉的BTree特性如下:

  • 树中每个节点最多包含m个孩子。
  • 除根节点与叶子节点外,每个节点至少有[ceil(m/2)]个孩子。
  • 若根节点不是叶子节点,则至少有两个孩子。
  • 所有的叶子节点都在同一层。
  • 每个非叶子节点由n个key与n+1个指针组成,其中[ceil(m/2)-1] <= n <= m-1

以5叉BTree为例,key的数量:公式推导[ceil(m/2)-1] <= n <= m-1。所以 2 <= n <=4 。当n>4时,中间节点分裂到父节点,两边节点分裂。

插入 C N G A H E K Q M F W L T Z D P R X Y S 数据为例。

演变过程如下:

  1. 插入前4个字母 C N G A

    1555944126588

  2. 插入H,n>4,中间元素G字母向上分裂到新的节点

    1555944549825

  3. 插入E,K,Q不需要分裂

    1555944596893

  4. 插入M,中间元素M字母向上分裂到父节点G

    1555944652560

  5. 插入F,W,L,T不需要分裂

    1555944686928

  6. 插入Z,中间元素T向上分裂到父节点中

    1555944713486

  7. 插入D,中间元素D向上分裂到父节点中。然后插入P,R,X,Y不需要分裂

    1555944749984

  8. 最后插入S,NPQR节点n>5,中间节点Q向上分裂,但分裂后父节点DGMT的n>5,中间节点M向上分裂

    1555944848294

到此,该BTREE树就已经构建完成了。

BTREE树 和 二叉树 相比:

  • 查询数据的效率更高, 因为对于相同的数据量来说,BTREE的层级结构比二叉树小,因此搜索速度快。
3、B+TREE(B+树)结构

B+Tree为BTree的变种,B+Tree与BTree的区别为:

  1. n叉B+Tree最多含有n个key,而BTree最多含有n-1个key。
  2. B+Tree的叶子节点保存所有的key信息,依key大小顺序排列。
  3. 所有的非叶子节点都可以看作是key的索引部分。

1555906287178

由于B+Tree只有叶子节点保存key信息,查询任何key都要从root走到叶子。所以B+Tree的查询效率更加稳定

4、MySQL中的B+Tree

MySql索引数据结构对经典的B+Tree进行了优化:在原B+Tree的基础上,增加一个指向相邻叶子节点的链表指针,就形成了带有顺序指针的B+Tree,提高区间访问的性能

MySQL中的 B+Tree 索引结构示意图:

1555906287178

5、聚簇索引与非聚簇索引(了解)
  • 聚簇索引:将数据存储与索引放到了一块,找到索引也就找到了数据
  • 非聚簇索引:将数据存储于索引分开结构,索引结构的叶子节点指向了数据的对应行,myisam通过key_buffer把索引先缓存到内存中,当需要访问数据时(通过索引访问数据),在内存中直接搜索索引,然后通过索引找到磁盘相应数据,这也就是为什么索引不在key buffer命中时,速度慢的原因

澄清一个概念:innodb中,在聚簇索引之上创建的索引称之为辅助索引,辅助索引访问数据总是需要二次查找,非聚簇索引都是辅助索引,像复合索引、前缀索引、唯一索引,辅助索引叶子节点存储的不再是行的物理位置,而是主键值。

何时使用聚簇索引与非聚簇索引

rcil81aoyd

聚簇索引具有唯一性

由于聚簇索引是将数据跟索引结构放到一块,因此一个表仅有一个聚簇索引

一个误区:把主键自动设为聚簇索引

聚簇索引默认是主键,如果表中没有定义主键,InnoDB 会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB 会隐式定义一个主键来作为聚簇索引。InnoDB 只聚集在同一个页面中的记录。包含相邻键值的页面可能相距甚远。如果你已经设置了主键为聚簇索引,必须先删除主键,然后添加我们想要的聚簇索引,最后恢复设置主键即可

此时其他索引只能被定义为非聚簇索引。这个是最大的误区。有的主键还是无意义的自动增量字段,那样的话Clustered index对效率的帮助,完全被浪费了。

刚才说到了,聚簇索引性能最好而且具有唯一性,所以非常珍贵,必须慎重设置。一般要根据这个表最常用的SQL查询方式来进行选择,某个字段作为聚簇索引,或组合聚簇索引,这个要看实际情况。

记住我们的最终目的就是在相同结果集情况下,尽可能减少逻辑IO

结合图再仔细点看

2w157wzq2u

2q05hsflfa

  1. InnoDB使用的是聚簇索引,将主键组织到一棵B+树中,而行数据就储存在叶子节点上,若使用”where id = 14”这样的条件查找主键,则按照B+树的检索算法即可查找到对应的叶节点,之后获得行数据
  2. 对Name列进行条件搜索,则需要两个步骤第一步在辅助索引B+树中检索Name,到达其叶子节点获取对应的主键。第二步使用主键在主索引B+树种再执行一次B+树检索操作,最终到达叶子节点即可获取整行数据。(重点在于通过其他键需要建立辅助索引

MyISM使用的是非聚簇索引,非聚簇索引的两棵B+树看上去没什么不同,节点的结构完全一致只是存储的内容不同而已,主键索引B+树的节点存储了主键,辅助键索引B+树存储了辅助键。表数据存储在独立的地方,这两颗B+树的叶子节点都使用一个地址指向真正的表数据,对于表数据来说,这两个键没有任何差别。由于索引树是独立的,通过辅助键检索无需访问主键的索引树

聚簇索引的优势

看上去聚簇索引的效率明显要低于非聚簇索引,因为每次使用辅助索引检索都要经过两次B+树查找,这不是多此一举吗?聚簇索引的优势在哪?

  1. 由于行数据和叶子节点存储在一起,同一页中会有多条行数据,访问同一数据页不同行记录时,已经把页加载到了Buffer中,再次访问的时候,会在内存中完成访问,不必访问磁盘。这样主键和行数据是一起被载入内存的,找到叶子节点就可以立刻将行数据返回了,如果按照主键Id来组织数据,获得数据更快
  2. 辅助索引使用主键作为”指针”而不是使用地址值作为指针的好处是,减少了当出现行移动或者数据页分裂时辅助索引的维护工作使用主键值当作指针会让辅助索引占用更多的空间,换来的好处是InnoDB在移动行时无须更新辅助索引中的这个”指针”**。也就是说行的位置(实现中通过16K的Page来定位)会随着**数据库里数据的修改而发生变化(前面的B+树节点分裂以及Page的分裂),使用聚簇索引就可以保证不管这个主键B+树的节点如何变化,辅助索引树都不受影响
  3. 聚簇索引适合用在排序的场合,非聚簇索引不适合
  4. 取出一定范围数据的时候,使用用聚簇索引
  5. 二级索引需要两次索引查找,而不是一次才能取到数据,因为存储引擎第一次需要通过二级索引找到索引的叶子节点,从而找到数据的主键,然后在聚簇索引中用主键再次查找索引,再找到数据
  6. 可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户 ID 来聚集数据,这样只需要从磁盘读取少数的数据页就能获取某个用户的全部邮件。如果没有使用聚簇索引,则每封邮件都可能导致一次磁盘 I/O。

聚簇索引的劣势

  1. 维护索引很昂贵,特别是插入新行或者主键被更新导至要分页(page split)的时候。建议在大量插入新行后,选在负载较低的时间段,通过OPTIMIZE TABLE优化表,因为必须被移动的行数据可能造成碎片。使用独享表空间可以弱化碎片
  2. 表因为使用UUId(随机ID)作为主键,使数据存储稀疏,这就会出现聚簇索引有可能有比全表扫面更慢,

iywj5q0imm

所以建议使用int的auto_increment作为主键

td2fso5cth

主键的值是顺序的,所以 InnoDB 把每一条记录都存储在上一条记录的后面。当达到页的最大填充因子时(InnoDB 默认的最大填充因子是页大小的 15/16,留出部分空间用于以后修改),下一条记录就会写入新的页中。一旦数据按照这种顺序的方式加载,主键页就会近似于被顺序的记录填满(二级索引页可能是不一样的)

  1. 如果主键比较大的话,那辅助索引将会变的更大,因为辅助索引的叶子存储的是主键值;过长的主键值,会导致非叶子节点占用占用更多的物理空间

为什么主键通常建议使用自增id

聚簇索引的数据的物理存放顺序与索引顺序是一致的,即:只要索引是相邻的,那么对应的数据一定也是相邻地存放在磁盘上的。如果主键不是自增id,那么可以想 象,它会干些什么,不断地调整数据的物理地址、分页,当然也有其他一些措施来减少这些操作,但却无法彻底避免。但,如果是自增的,那就简单了,它只需要一 页一页地写,索引结构相对紧凑,磁盘碎片少,效率也高。

因为MyISAM的主索引并非聚簇索引,那么他的数据的物理地址必然是凌乱的,拿到这些物理地址,按照合适的算法进行I/O读取,于是开始不停的寻道不停的旋转聚簇索引则只需一次I/O。(强烈的对比)

不过,如果涉及到大数据量的排序、全表扫描、count之类的操作的话,还是MyISAM占优势些,因为索引所占空间小,这些操作是需要在内存中完成的

mysql中聚簇索引的设定

聚簇索引默认是主键,如果表中没有定义主键,InnoDB 会选择一个唯一的非空索引代替。如果没有这样的索引,InnoDB 会隐式定义一个主键来作为聚簇索引。InnoDB 只聚集在同一个页面中的记录。包含相邻健值的页面可能相距甚远。

6、full-text全文索引

全文索引(也称全文检索)是目前搜索引擎使用的一种关键技术。它能够利用【分词技术】等多种算法智能分析出文本文字中关键词的频率和重要性,然后按照一定的算法规则智能地筛选出我们想要的搜索结果。

1
2
3
4
5
6
7
CREATE TABLE `article` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`title` varchar(200) DEFAULT NULL,
`content` text,
PRIMARY KEY (`id`),
FULLTEXT KEY `title` (`title`,`content`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

不同于like方式的的查询:

1
SELECT * FROM article WHERE content LIKE ‘%查询字符串%’;

全文索引用match+against方式查询:

1
SELECT * FROM article WHERE MATCH(title,content) AGAINST (‘查询字符串’);

明显的提高查询效率。

限制:

  • mysql5.6.4以前只有Myisam支持,5.6.4版本以后innodb才支持,但是官方版本不支持中文分词,需要第三方分词插件。
  • 5.7以后官方支持中文分词。
  • 随着大数据时代的到来,关系型数据库应对全文索引的需求已力不从心,逐渐被 solr,elasticSearch等专门的搜索引擎所替代。
7、Hash索引
  • Hash索引只有Memory,NDB两种引擎支持,Memory引擎默认支持Hash索引,如果多个hash值相同,出现哈希碰撞,那么索引以链表方式存储。
  • NoSql采用此索引结构。
8、R-Tree索引
  • R-Tree在mysql很少使用,仅支持geometry数据类型,支持该类型的存储引擎只有myisam、bdb、innodb、ndb、archive几种。
  • 相对于b-tree,r-tree的优势在于==范围查找==。

4、索引分类

  1. 单值索引 :即一个索引只包含单个列,一个表可以有多个单列索引

    image-20210901011454316

  2. 唯一索引 :索引列的值必须唯一,但允许有空值

    image-20210901011537465

  3. 复合索引 :即一个索引包含多个列

    image-20210901011618614

  4. 主键索引:设定为主键后数据库会自动建立索引,innodb为聚簇索引

    • 随表一起建索引:

      1
      2
      3
      CREATE TABLE customer (id INT(10) UNSIGNED  AUTO_INCREMENT ,customer_no VARCHAR(200),customer_name VARCHAR(200),
      PRIMARY KEY(id)
      );

      使用 AUTO_INCREMENT 关键字的列必须有索引(只要有索引就行)。

    • 单独建主键索引:

      1
      ALTER TABLE customer add PRIMARY KEY customer(customer_no);  
    • 删除建主键索引:

      1
      ALTER TABLE customer drop PRIMARY KEY ;  
    • 修改建主键索引:必须先删除掉(drop)原索引,再新建(add)索引

5、索引语法

索引在创建表的时候,可以同时创建, 也可以随时增加新的索引。

准备环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 创建数据库
create database demo_01 default charset=utf8mb4;

use demo_01;

-- 创建city表
CREATE TABLE `city` (
`city_id` int(11) NOT NULL AUTO_INCREMENT,
`city_name` varchar(50) NOT NULL,
`country_id` int(11) NOT NULL,
PRIMARY KEY (`city_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 创建country表
CREATE TABLE `country` (
`country_id` int(11) NOT NULL AUTO_INCREMENT,
`country_name` varchar(100) NOT NULL,
PRIMARY KEY (`country_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 插入数据
insert into `city` (`city_id`, `city_name`, `country_id`) values(1,'西安',1);
insert into `city` (`city_id`, `city_name`, `country_id`) values(2,'NewYork',2);
insert into `city` (`city_id`, `city_name`, `country_id`) values(3,'北京',1);
insert into `city` (`city_id`, `city_name`, `country_id`) values(4,'上海',1);

-- 插入数据
insert into `country` (`country_id`, `country_name`) values(1,'China');
insert into `country` (`country_id`, `country_name`) values(2,'America');
insert into `country` (`country_id`, `country_name`) values(3,'Japan');
insert into `country` (`country_id`, `country_name`) values(4,'UK');
1、创建索引

语法:

1
2
3
4
5
CREATE 	[UNIQUE|FULLTEXT|SPATIAL]  INDEX index_name 
[USING index_type]
ON tbl_name(index_col_name,...)

index_col_name : column_name[(length)][ASC | DESC]

示例 : 为city表中的city_name字段创建索引 ;

1551438009843

2、查看索引

语法:

1
show index from table_name;

示例:查看city表中的索引信息(其中加上\G可以将查询到的数据以row的方式展示,方便查看)

1551440511890

1551440544483

3、删除索引

语法 :

1
DROP INDEX index_name ON tbl_name;

示例 : 想要删除city表上的索引idx_city_name,可以操作如下:

1551438238293

4、ALTER命令(添加索引)
1
2
3
4
5
6
7
8
9
10
11
-- 该语句添加一个主键,这意味着索引值必须是唯一的,且不能为NULL
alter table tb_name add primary key(column_list);

-- 这条语句创建索引的值必须是唯一的(除了NULL外,NULL可能会出现多次)
alter table tb_name add unique index_name(column_list);

-- 添加普通索引, 索引值可以出现多次。
alter table tb_name add index index_name(column_list);

-- 该语句指定了索引为FULLTEXT, 用于全文索引
alter table tb_name add fulltext index_name(column_list);

6、索引设计原则

索引的设计可以遵循一些已有的原则,创建索引的时候请尽量考虑符合这些原则,便于提升索引的使用效率,更高效的使用索引。

  • 主键自动建立唯一索引

  • 查询频次较高,且数据量比较大的表建立索引。

  • 索引字段的选择,最佳候选列应当从where子句的条件中提取,如果where子句中的组合比较多,那么应当挑选最常用、过滤效果最好的列的组合。

  • 使用唯一索引,区分度越高,使用索引的效率越高。

  • 查询中与其它表关联的字段,外键关系建立索引

  • 查询中排序的字段,排序字段若通过索引去访问将大大提高排序速度

    • group by 和 order by 后面的字段有索引大大提高效率
  • 查询中统计或者分组字段(分组包含着排序,因为在分组之前会先进行排序(当然也可以设置不排序))

  • 索引可以有效的提升查询数据的效率,但索引数量不是多多益善,索引越多,维护索引的代价自然也就水涨船高。对于插入、更新、删除等DML操作比较频繁的表来说,索引过多,会引入相当高的维护代价,降低DML操作的效率,增加相应操作的时间消耗。另外索引过多的话,MySQL也会犯选择困难病,虽然最终仍然会找到一个可用的索引,但无疑提高了选择的代价

  • 使用短索引,索引创建之后也是使用硬盘来存储的,因此提升索引访问的I/O效率,也可以提升总体的访问效率。假如构成索引的字段总长度比较短,那么在给定大小的存储块内可以存储更多的索引值,相应的可以有效的提升MySQL访问索引的I/O效率。

  • 在高并发下倾向创建组合索引

  • 利用最左前缀,N个列组合而成的组合索引,那么相当于是创建了N个索引,如果查询时where子句中使用了组成该索引的前几个字段,那么这条查询SQL可以利用组合索引来提升查询效率。

    • -- 创建复合索引
      CREATE INDEX idx_name_email_status ON tb_seller(NAME,email,STATUS);
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53

      - 就相当于:

      - 对name 创建索引 ;
      - 对name , email 创建了索引 ;
      - 对name , email, status 创建了索引 ;

      **哪些情况不要创建索引:**

      - 表记录太少(没必要)
      - 经常增删改的表
      - 提高了查询速度,同时却会降低更新表的速度,如对表进行INSERT、UPDATE和DELETE。
      - 因为更新表时,MySQL不仅要保存数据,还要保存一下索引文件
      - Where条件里用不到的字段不创建索引,索引建多了影响 增删改 的效率
      - 数据重复且分布平均的表字段,因此应该**只为最经常查询和最经常排序的数据列建立索引**。
      - **注意,如果某个数据列包含许多重复的内容,为它建立索引就没有太大的实际效果。**
      - 假如一个表有10万行记录,有一个字段A只有T和F两种值,且每一个值的分布概率大约为50%,那么对这种表A字段建索引一般不会提高数据库的程序速度
      - 索引的选择性是指索引列中不同值的数目与表中记录数的比。即:如果一个表中有2000条记录,表索引列有1980个不同的值,那么这个索引的选择性就是1980/2000 = 0.99。**一个索引的选择性越接近于1,这个索引的效率就越高。**

      -----





      ### 2、视图(View)

      #### 1、视图概述

      **视图(View)是一种虚拟存在的表**。**视图并不在数据库中实际存在,行和列数据来自定义视图的==查询==中使用的表,并且是在使用视图时动态生成的。**

      通俗的讲,**视图就是一条SELECT语句执行后返回的结果集**。所以我们在创建视图的时候,主要的工作就落在创建这条SQL查询语句上。

      视图相对于普通的表的优势主要包括以下几项:

      - **简单**:**使用视图的用户完全不需要关心后面对应的表的结构、关联条件和筛选条件**,对用户来说已经是过滤好的复合条件的结果集。
      - **安全**:**使用视图的用户只能访问他们被允许查询的结果集**,对表的权限管理并不能限制到某个行某个列,但是通过视图就可以简单的实现。
      - **数据独立**:一旦视图的结构确定了,可以**屏蔽表结构变化对用户的影响,源表增加列对视图没有影响;源表修改列名,则可以通过修改视图来解决,不会造成对访问者的影响**。



      #### 2、创建或者修改视图

      创建视图的语法为:

      ```sql
      CREATE [OR REPLACE] [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]

      VIEW view_name [(column_list)]

      AS select_statement

      [WITH [CASCADED | LOCAL] CHECK OPTION]

修改视图的语法为:

1
2
3
4
5
6
7
ALTER [ALGORITHM = {UNDEFINED | MERGE | TEMPTABLE}]

VIEW view_name [(column_list)]

AS select_statement

[WITH [CASCADED | LOCAL] CHECK OPTION]

其中:选项WITH [CASCADED | LOCAL] CHECK OPTION 决定了是否允许更新数据使记录不再满足视图的条件

  • LOCAL : 只要满足本视图的条件就可以更新。
  • CASCADED : 必须满足所有针对该视图的所有视图的条件才可以更新。 默认值

示例:创建city_country_view视图,执行如下SQL:

1
2
3
create or replace view city_country_view 
as
select t.*,c.country_name from country c , city t where c.country_id = t.country_id;

由于视图是一种虚拟的表,所以可以使用操作表的SQL语句对视图进行查询:

查询视图 :

1551503428635

当然,由于视图是一种虚拟的表,所以也可以对视图进行修改操作:

1
update city_country_view set city_name = 'GuangDong' where city_id = 1; 

此时,修改操作不仅会修改视图上的表的数据,而且也会同步修改底层的表中的数据。

注意:一般不要在视图上进行修改,视图是用来简化我们的==查询==操作,方便我们进行数据查询的。

3、 查看视图

从 MySQL 5.1 版本开始,使用 SHOW TABLES 命令的时候不仅显示表的名字,同时也会显示视图的名字,而不存在单独显示视图的 SHOW VIEWS 命令。

1551537565159

同样,在使用 SHOW TABLE STATUS 命令的时候,不但可以显示表的信息,同时也可以显示视图的信息。

1551537646323

如果需要查询某个视图的定义,可以使用 SHOW CREATE VIEW 命令进行查看:

1551588962944

4、删除视图

语法 :

1
DROP VIEW [IF EXISTS] view_name [, view_name] ...[RESTRICT | CASCADE]	

示例,删除视图city_country_view :

1
DROP VIEW city_country_view ;

3、存储过程(Procedure)和函数(Function)

1、存储过程和函数概述

存储过程和函数是:事先经过编译并存储在数据库中的一段 SQL 语句的集合,调用存储过程和函数可以简化应用开发人员的很多工作,减少数据在数据库和应用服务器之间的传输,对于提高数据处理的效率是有好处的。

存储过程和函数的区别在于函数必须有返回值,而存储过程没有:

  • 函数(function): 是一个有返回值的过程 ;
  • 过程(procedure): 是一个没有返回值的函数 ;

其实存储过程与存储函数的作用并没有太大的区别:

  • 存储函数可以获取返回值
  • 存储过程可以通过OUT也能获取返回值

2、存储过程

1、创建存储过程
1
2
3
4
CREATE PROCEDURE procedure_name ([proc_parameter[,...]])
begin
-- SQL语句
end ;

示例:

1
2
3
4
5
6
7
8
delimiter $

create procedure pro_test1()
begin
select 'Hello Mysql' ;
end$

delimiter ;

知识小贴士

DELIMITER

  • 该关键字用来声明SQL语句的分隔符,告诉 MySQL 解释器,该段命令是否已经结束了,mysql是否可以执行了。
  • 默认情况下,delimiter是分号;
  • 在命令行客户端中,如果有一行命令以;结束,那么回车后,mysql将会执行该命令。
  • 如果要创建存储过程的话,在定义里面的sql语句时,使用;会让mysql执行命令,而此时的命令是不完全的,会报错。此时使用DELIMITER将分隔符修改为其他符号,等到创建存储过程之后,在将分隔符改回;就行。
2、调用存储过程
1
call procedure_name() ;	
3、查看存储过程
1
2
3
4
5
6
7
8
-- 查询db_name数据库中的所有的存储过程
select name from mysql.proc where db='db_name';

-- 查询存储过程的状态信息
show procedure status;

-- 查询某个存储过程的定义
show create procedure test.pro_test1 \G;
4、删除存储过程
1
DROP PROCEDURE  [IF EXISTS] sp_name;
5、在存储过程的sql当中的语法

存储过程是可以编程的,意味着可以使用变量表达式控制结构 , 来完成比较复杂的功能。

1、变量
  • DECLARE

    • 通过 DECLARE 可以定义一个局部变量,该变量的作用范围只能在 BEGIN…END 块中。

    • DECLARE var_name[,...] type [DEFAULT value]
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15

      - 示例:

      - ```sql
      delimiter $

      create procedure pro_test2()
      begin
      declare num int default 5;
      select num+ 10;
      -- concat('xxx','ooo') 把里面的东西连接成一个字符串
      -- select concat('num的值为:',num);
      end$

      delimiter;
  • SET

    • 直接赋值使用 SET,可以赋常量或者赋表达式,具体语法如下:

    • SET var_name = expr [, var_name = expr] ...
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14

      - 示例:

      - ```sql
      DELIMITER $

      CREATE PROCEDURE pro_test3()
      BEGIN
      DECLARE NAME VARCHAR(20);
      SET NAME = 'MYSQL';
      SELECT NAME ;
      END$

      DELIMITER ;
    • 也可以通过select … into 方式进行赋值操作:

    • DELIMITER $
      
      CREATE  PROCEDURE pro_test5()
      BEGIN
          declare  countnum int;
          select count(*) into countnum from city;
          select concat('city表当中的记录数为:',num);
      END$
      
      DELIMITER ;
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      ###### 2、if条件判断

      语法结构:

      ```sql
      if search_condition then statement_list

      [elseif search_condition then statement_list] ...

      [else statement_list]

      end if;

需求:根据定义的身高变量,判定当前身高的所属的身材类型

  • 180 及以上:身材高挑
  • 170 - 180:标准身材
  • 170 以下:一般身材

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
delimiter $

create procedure pro_test6()
begin
declare height int default 175;
declare description varchar(50);

if height >= 180 then
set description = '身材高挑';
elseif height >= 170 and height < 180 then
set description = '标准身材';
else
set description = '一般身材';
end if;

select description ;
-- select concat('身高:',height,'对应的身材类型:',description)
end$

delimiter ;

调用结果为:

1552057035580

3、传递参数

语法格式:

1
create procedure procedure_name([in/out/inout] 参数名   参数类型)
  • IN该参数可以作为输入,也就是需要调用方传入值,默认
  • OUT该参数作为输出,也就是该参数可以作为返回值
  • INOUT既可以作为输入参数,也可以作为输出参数

IN - 输入

需求:根据定义的身高变量,判定当前身高的所属的身材类型

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
delimiter $

create procedure pro_test5(in height int)
begin
declare description varchar(50) default '';
if height >= 180 then
set description='身材高挑';
elseif height >= 170 and height < 180 then
set description='标准身材';
else
set description='一般身材';
end if;
select concat('身高 ', height , '对应的身材类型为:',description);
end$

delimiter ;

OUT-输出

需求:根据传入的身高变量,获取当前身高的所属的身材类型

示例:

1
2
3
4
5
6
7
8
9
10
create procedure pro_test5(in height int , out description varchar(100))
begin
if height >= 180 then
set description='身材高挑';
elseif height >= 170 and height < 180 then
set description='标准身材';
else
set description='一般身材';
end if;
end$

调用:

1
2
3
call pro_test5(168, @description)$

select @description$

小知识 

  • @description:这种变量要在变量名称前面加上“@”符号,叫做用户会话变量,代表整个会话过程他都是有作用的,这个类似于全局变量一样
  • @@global.sort_buffer_size:这种在变量前加上 “@@” 符号,叫做 系统变量
4、case结构

语法结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 方式一
CASE case_value

WHEN when_value THEN statement_list

[WHEN when_value THEN statement_list] ...

[ELSE statement_list]

END CASE;


-- 方式二

CASE

WHEN search_condition THEN statement_list

[WHEN search_condition THEN statement_list] ...

[ELSE statement_list]

END CASE;

需求:给定一个月份,然后计算出所在的季度

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
delimiter $

create procedure pro_test9(month int)
begin
declare result varchar(20);
case
when month >= 1 and month <=3 then
set result = '第一季度';
when month >= 4 and month <=6 then
set result = '第二季度';
when month >= 7 and month <=9 then
set result = '第三季度';
when month >= 10 and month <=12 then
set result = '第四季度';
end case;

select concat('您输入的月份为 :', month , ' , 该月份为 : ' , result) as content ;

end$

delimiter ;
5、while循环

语法结构:

1
2
3
4
5
while search_condition do

statement_list

end while;

需求:计算从1加到n的值

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
delimiter $

create procedure pro_test8(n int)
begin
declare total int default 0;
declare num int default 1;
while num<=n do
set total = total + num;
set num = num + 1;
end while;
select total;
end$

delimiter ;
6、repeat结构

有条件的循环控制语句,当满足条件的时候退出循环。

while 是满足条件才执行,repeat 是满足条件就退出循环。

语法结构:

1
2
3
4
5
6
7
REPEAT

statement_list

UNTIL search_condition

END REPEAT;

需求:计算从1加到n的值

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
delimiter $

create procedure pro_test10(n int)
begin
declare total int default 0;

repeat
set total = total + n;
set n = n - 1;
until n=0
end repeat;

select total ;

end$


delimiter ;

注意:until后面的语句不要加上 ; ,否则语法报错。

7、loop语句

LOOP 实现简单的循环,退出循环的条件需要使用其他的语句定义,通常可以使用 LEAVE 语句实现,具体语法如下:

1
2
3
4
5
[begin_label:] LOOP

statement_list

END LOOP [end_label]

如果不在 statement_list 中增加退出循环的语句,那么 LOOP 语句可以用来实现简单的死循环。

8、leave语句

用来从标注的流程构造中退出,通常和 BEGIN … END 或者循环一起使用。下面是一个使用 LOOP 和 LEAVE 的简单例子,退出循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
delimiter $

CREATE PROCEDURE pro_test11(n int)
BEGIN
declare total int default 0;

ins: LOOP

IF n <= 0 then
leave ins;
END IF;

set total = total + n;
set n = n - 1;

END LOOP ins;

select total;
END$

delimiter ;
9、游标/光标(Cursor)

游标是用来存储查询结果集的数据类型,在存储过程和函数中可以使用光标对结果集进行循环的处理。

光标的使用包括光标的声明、OPEN、FETCH 和 CLOSE,其语法分别如下:

声明光标:

1
DECLARE cursor_name CURSOR FOR select_statement ;

OPEN 光标:

1
OPEN cursor_name ;

FETCH 光标:(每fetch一次,指针往下走一个)

1
FETCH cursor_name INTO var_name [, var_name] ...

CLOSE 光标:

1
CLOSE cursor_name ;

示例:

初始化脚本:

1
2
3
4
5
6
7
8
9
10
create table emp(
id int(11) not null auto_increment ,
name varchar(50) not null comment '姓名',
age int(11) comment '年龄',
salary int(11) comment '薪水',
primary key(`id`)
)engine=innodb default charset=utf8 ;

insert into emp(id,name,age,salary) values(null,'金毛狮王',55,3800),(null,'白眉鹰王',60,4000),(null,'青翼蝠王',38,2800),(null,'紫衫龙王',42,1800);

查询emp表中数据,并逐行获取进行展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
create procedure pro_test11()
begin
declare e_id int(11);
declare e_name varchar(50);
declare e_age int(11);
declare e_salary int(11);
declare emp_result cursor for select * from emp;

open emp_result;

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

fetch emp_result into e_id,e_name,e_age,e_salary;
select concat('id=',e_id , ', name=',e_name, ', age=', e_age, ', 薪资为: ',e_salary);

close emp_result;
end$

结果:前四条数据被成功fetch出来展示,最后一次fetch由于表中已经没有数据,所以会报错:

1
ERROR 1329 (020000): No data - zero rows fetched ,selected, or processed

通过循环结构,获取游标中的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
DELIMITER $

create procedure pro_test12()
begin
DECLARE id int(11);
DECLARE name varchar(50);
DECLARE age int(11);
DECLARE salary int(11);
DECLARE has_data int default 1;

DECLARE emp_result CURSOR FOR select * from emp;
-- 这里的条件声明必须放在游标声明之后,否则报错
DECLARE EXIT HANDLER FOR NOT FOUND set has_data = 0;

open emp_result;

repeat
fetch emp_result into id , name , age , salary;
select concat('id为',id, ', name 为' ,name , ', age为 ' ,age , ', 薪水为: ', salary);
until has_data = 0
end repeat;

close emp_result;
end$

DELIMITER ;

注意:这里的条件声明必须放在游标声明之后,否则报错

3、存储函数

语法结构:

1
2
3
4
5
CREATE FUNCTION function_name([param type ... ]) 
RETURNS type
BEGIN
-- SQL语句
END;

案例:定义一个存储函数,请求满足条件的总记录数

1
2
3
4
5
6
7
8
9
10
11
12
13
delimiter $

create function count_city(countryId int)
returns int
begin
declare cnum int ;

select count(*) into cnum from city where country_id = countryId;

return cnum;
end$

delimiter ;

调用:

1
2
3
select count_city(1);

select count_city(2);

4、触发器(Trigger)

1、介绍

触发器是与表有关的数据库对象,指在 insert/update/delete 之前或之后,触发并执行触发器中定义的SQL语句集合

触发器的这种特性可以协助应用在数据库端确保数据的完整性,日志记录,数据校验等操作 。

使用别名 OLDNEW 来引用触发器中发生变化的记录内容,这与其他的数据库是相似的。

  • mysql触发器还只支持行级触发器,不支持语句级触发器。
  • Oracle数据库既支持行级触发器,又支持语句级触发器。
触发器类型 NEW 和 OLD 的使用
INSERT 型触发器 NEW 表示将要或者已经新增的数据
UPDATE 型触发器 OLD 表示修改之前的数据,NEW 表示将要或已经修改后的数据
DELETE 型触发器 OLD 表示将要或者已经删除的数据

2、创建触发器

语法结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
create trigger trigger_name 

before/after insert/update/delete

on tbl_name

[ for each row ] -- 行级触发器

begin

trigger_stmt ;

end;

示例:

需求:通过触发器记录 emp 表的数据变更日志,包含增加、修改、删除

首先创建一张日志表:

1
2
3
4
5
6
7
8
create table emp_logs(
id int(11) not null auto_increment,
operation varchar(20) not null comment '操作类型, insert/update/delete',
operate_time datetime not null comment '操作时间',
operate_id int(11) not null comment '操作表的ID',
operate_params varchar(500) comment '操作参数',
primary key(`id`)
)engine=innodb default charset=utf8;

创建 insert 型触发器,完成插入数据时的日志记录:

1
2
3
4
5
6
7
8
9
10
11
DELIMITER $

create trigger emp_logs_insert_trigger
after insert
on emp
for each row
begin
insert into emp_logs (id,operation,operate_time,operate_id,operate_params) values(null,'insert',now(),new.id,concat('插入后(id:',new.id,', name:',new.name,', age:',new.age,', salary:',new.salary,')'));
end $

DELIMITER ;

创建 update 型触发器,完成更新数据时的日志记录:

1
2
3
4
5
6
7
8
9
10
11
DELIMITER $

create trigger emp_logs_update_trigger
after update
on emp
for each row
begin
insert into emp_logs (id,operation,operate_time,operate_id,operate_params) values(null,'update',now(),new.id,concat('修改前(id:',old.id,', name:',old.name,', age:',old.age,', salary:',old.salary,') , 修改后(id',new.id, 'name:',new.name,', age:',new.age,', salary:',new.salary,')'));
end $

DELIMITER ;

创建delete 行的触发器 , 完成删除数据时的日志记录:

1
2
3
4
5
6
7
8
9
10
11
DELIMITER $

create trigger emp_logs_delete_trigger
after delete
on emp
for each row
begin
insert into emp_logs (id,operation,operate_time,operate_id,operate_params) values(null,'delete',now(),old.id,concat('删除前(id:',old.id,', name:',old.name,', age:',old.age,', salary:',old.salary,')'));
end $

DELIMITER ;

测试:

1
2
3
4
5
6
7
8
insert into emp(id,name,age,salary) values(null, '光明左使',30,3500);
insert into emp(id,name,age,salary) values(null, '光明右使',33,3200);

update emp set age = 39 where id = 3;

delete from emp where id = 5;

select * from emp_logs;

3、删除触发器

语法结构:

1
drop trigger [schema_name.]trigger_name

如果没有指定 schema_name,默认为当前数据库 。

4、查看触发器

可以通过执行 SHOW TRIGGERS 命令查看触发器的状态、语法等信息。

语法结构:

1
show triggers

5、Mysql的体系结构概览

171214401286615

整个MySQL Server由以下组成

  • Connection Pool:连接池组件
  • Management Services & Utilities:管理服务和工具组件
  • SQL Interface:SQL接口组件
  • Parser:查询分析器组件
  • Optimizer:优化器组件
  • Caches & Buffers:缓冲池组件
  • Pluggable Storage Engines:存储引擎
  • File System:文件系统

整个MySQL Server 从上往下可以分为以下四层:

  1. 连接层
    • 最上层是一些客户端和链接服务,包含本地sock 通信和大多数基于客户端/服务端工具实现的类似于 TCP/IP的通信。
    • 主要完成一些类似于连接处理、授权认证、及相关的安全方案。
    • 在该层上引入了线程池的概念,为通过认证安全接入的客户端提供线程。
    • 同样在该层上可以实现基于SSL的安全链接。
    • 服务器也会为安全接入的每个客户端验证它所具有的操作权限。
  2. 服务层
    • 第二层架构主要完成大多数的核心服务功能,如SQL接口,并完成缓存的查询,SQL的分析和优化,部分内置函数的执行。
    • 所有跨存储引擎的功能也在这一层实现,如 过程、函数等。
    • 在该层,服务器会解析查询并创建相应的内部解析树,并对其完成相应的优化如确定表的查询的顺序,是否利用索引等, 最后生成相应的执行操作。
    • 如果是select语句,服务器还会查询内部的缓存,如果缓存空间足够大,这样在解决大量读操作的环境中能够很好的提升系统的性能。
  3. 引擎层
    • 存储引擎层, 存储引擎真正的负责了MySQL中数据的存储和提取,服务器通过API和存储引擎进行通信。
    • 不同的存储引擎具有不同的功能,这样我们可以根据自己的需要,来选取合适的存储引擎。
  4. 存储层
    • 数据存储层, 主要是将数据存储在文件系统之上,并完成与存储引擎的交互。

和其他数据库相比,MySQL有点与众不同,它的架构可以在多种不同场景中应用并发挥良好作用。主要体现在存储引擎上,插件式的存储引擎架构,将查询处理和其他的系统任务以及数据的存储提取分离。这种架构可以根据业务的需求和实际需要选择合适的存储引擎。

查询流程图:

img

首先,mysql的查询流程大致是:

  • mysql客户端通过协议与mysql服务器建连接,发送查询语句,先检查查询缓存,如果命中(一模一样的sql才能命中),直接返回结果,否则进行语句解析。也就是说,在解析查询之前,服务器会先访问查询缓存(query cache)——它存储SELECT语句以及相应的查询结果集。如果某个查询结果已经位于缓存中,服务器就不会再对查询进行解析、优化、以及执行。它仅仅将缓存中的结果返回给用户即可,这将大大提高系统的性能。

  • 语法解析器和预处理:首先mysql通过关键字将SQL语句进行解析,并生成一颗对应的“解析树”。mysql解析器将使用mysql语法规则验证和解析查询;预处理器则根据一些mysql规则进一步检查解析数是否合法。

  • 查询优化器当解析树被认为是合法的了,并且由优化器将其转化成执行计划。一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。

  • 然后,mysql默认使用的BTREE索引,并且一个大致方向是:无论怎么折腾sql,至少在目前来说,mysql最多只用到表中的一个索引。


6、存储引擎

1、存储引擎概述

  • 和大多数的数据库不同,MySQL中有一个存储引擎的概念,针对不同的存储需求可以选择最优的存储引擎。

  • 存储引擎就是存储数据,建立索引,更新查询数据等等技术的实现方式

  • 存储引擎是基于表的,而不是基于库的。所以存储引擎也可被称为表类型。所以数据库的每一张表都可以使用不同的存储引擎。

  • Oracle,SqlServer等数据库只有一种存储引擎。MySQL提供了插件式的存储引擎架构。所以MySQL存在多种存储引擎,可以根据需要使用相应引擎,或者编写存储引擎

  • MySQL5.0支持的存储引擎包含 : InnoDBMyISAMBDBMEMORYMERGEEXAMPLENDB ClusterARCHIVECSVBLACKHOLEFEDERATED等,其中InnoDB和BDB提供事务安全表,其他存储引擎是非事务安全表。

可以通过指定 show engines , 来查询当前数据库支持的存储引擎:

1551186043529

创建新表时如果不指定存储引擎,那么系统就会使用默认的存储引擎,MySQL5.5之前的默认存储引擎是MyISAM,5.5之后就改为了InnoDB。

查看Mysql数据库默认的存储引擎,指令:

1
show variables like '%storage_engine%';

1556086372754

2、各种存储引擎特性

下面重点介绍几种常用的存储引擎, 并对比各个存储引擎之间的区别, 如下表所示 :

特点 InnoDB MyISAM MEMORY MERGE NDB
存储限制 64TB 没有
事务安全 ==支持==
锁机制 ==行锁(适合高并发)== ==表锁== 表锁 表锁 行锁
B树索引 支持 支持 支持 支持 支持
哈希索引 支持
全文索引 支持(5.6版本之后) 支持
集群索引 支持
数据索引 支持 支持 支持
索引缓存 支持 支持 支持 支持 支持
数据可压缩 支持
空间使用 N/A
内存使用 中等
批量插入速度
支持外键 ==支持==(唯一支持外键的存储引擎)

下面我们将重点介绍最长使用的两种存储引擎: ==InnoDB==、==MyISAM== , 另外两种 MEMORY、MERGE , 了解即可。

1、InnoDB

InnoDB存储引擎是Mysql的默认存储引擎。InnoDB存储引擎提供了具有提交、回滚、崩溃恢复能力的事务安全。但是对比MyISAM的存储引擎,InnoDB写的处理效率差一些,并且会占用更多的磁盘空间以保留数据和索引。

InnoDB存储引擎不同于其他存储引擎的特点:

  • 事务控制

    • create table goods_innodb(
          id int NOT NULL AUTO_INCREMENT,
          name varchar(20) NOT NULL,
          primary key(id)
      )ENGINE=innodb DEFAULT CHARSET=utf8;
      
      1
      2
      3
      4
      5
      6
      7

      - ```sql
      start transaction;

      insert into goods_innodb(id,name)values(null,'Meta20');

      commit;
    • 1556075130115

    • 测试,发现在InnoDB中是存在事务的

  • 外键约束

    • MySQL支持外键的存储引擎只有InnoDB,在创建外键的时候,要求父表必须有对应的索引,子表在创建外键的时候,也会自动的创建对应的索引。

    • 下面两张表中 , country_innodb是父表 , country_id为主键索引,city_innodb表是子表,country_id字段为外键,对应于country_innodb表的主键country_id:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      create table country_innodb(
      country_id int NOT NULL AUTO_INCREMENT,
      country_name varchar(100) NOT NULL,
      primary key(country_id)
      )ENGINE=InnoDB DEFAULT CHARSET=utf8;

      create table city_innodb(
      city_id int NOT NULL AUTO_INCREMENT,
      city_name varchar(50) NOT NULL,
      country_id int NOT NULL,
      primary key(city_id),
      key idx_fk_country_id(country_id),
      CONSTRAINT `fk_city_country` FOREIGN KEY(country_id) REFERENCES country_innodb(country_id) ON DELETE RESTRICT ON UPDATE CASCADE
      )ENGINE=InnoDB DEFAULT CHARSET=utf8;

      insert into country_innodb values(null,'China'),(null,'America'),(null,'Japan');
      insert into city_innodb values(null,'Xian',1),(null,'NewYork',2),(null,'BeiJing',1);
    • 在创建索引时, 可以指定在删除、更新父表时,对子表进行的相应操作,包括:

      • RESTRICT
      • CASCADE
      • SET NULL
      • NO ACTION
    • RESTRICT和NO ACTION相同, 是指限制在子表有关联记录的情况下, 父表不能更新

      • 如 DELETE RESTRICT :表示在删除父表当中数据的时候,如果该数据有外键关联着子表的数据的话,则删除失败。
    • CASCADE 表示父表在更新或者删除时,更新或者删除子表对应的记录;

      • 如 UPDATE CASCADE : 表示在更新父表数据的时候,如果该数据有外键关联着子表的数据的话,则子表的数据也会跟着一起更新。
    • SET NULL 则表示父表在更新或者删除的时候,子表的对应字段被SET NULL

      • 如 DELETE SET NULL : 表示在删除数据的时候,如果该数据有外键关联着子表的数据的话,则子表对应的数据会被设置为null
    • 针对上面创建的两个表, 子表的外键指定是ON DELETE RESTRICT ON UPDATE CASCADE 方式的:

      • ON DELETE RESTRICT:那么在主表删除记录的时候, 如果子表有对应记录, 则不允许删除
      • ON UPDATE CASCADE:主表在更新记录的时候, 如果子表有对应记录, 则子表对应更新
    • 表中数据如下图所示:

      1556087540767

    • 外键信息可以使用如下两种方式查看:

      1
      show create table city_innodb ;

      1556087611295

    • 删除country_id为1 的country数据:(在主表删除记录的时候, 如果子表有对应记录, 则不允许删除)

      1
      delete from country_innodb where country_id = 1;

      1556087719145

    • 更新主表country表的字段 country_id:

      1
      update country_innodb set country_id = 100 where country_id = 1;

      1556087759615

    • 更新后, 子表的数据信息为:(主表在更新记录的时候, 如果子表有对应记录, 则子表对应更新)

      1556087793738

  • 存储方式

    • 在Linux环境下,数据库表的数据信息默认存储在 var/lib/mysql 下

    • InnoDB 存储表和索引有以下两种方式:

      1. 使用共享表空间存储, 这种方式创建的表的表结构保存在==.frm文件==中, 数据和索引保存在 ==innodb_data_home_dir== 和 ==innodb_data_file_path== 定义的表空间中,可以是多个文件。

      2. 使用多表空间存储, 这种方式创建的表的表结构仍然存在 ==.frm 文件==中,但是每个表的数据和索引单独保存在 ==.ibd== 中。

        1556075336630

2、MyISAM

MyISAM 不支持事务、也不支持外键,其优势是访问的速度快对事务的完整性没有要求或者以SELECT、INSERT为主的应用基本上都可以使用这个引擎来创建表

MyISAM有以下两个比较重要的特点:

  • 不支持事务

    1
    2
    3
    4
    5
    create table goods_myisam(
    id int NOT NULL AUTO_INCREMENT,
    name varchar(20) NOT NULL,
    primary key(id)
    )ENGINE=myisam DEFAULT CHARSET=utf8;
    • image-20210901130955510
    • 通过测试,我们发现,就算mysql显示的好像开启了事务,但是在MyISAM存储引擎中,是没有事务控制的,因此就算 start transaction 之后,执行sql在commit之前还是可以执行到mysql数据库当中的。
  • 文件存储方式

    • 每个MyISAM在磁盘上存储成3个文件,其文件名都和表名相同,但拓展名分别是:

      • .frm (存储表定义)
      • .MYD(MYData , 存储数据)
      • .MYI(MYIndex , 存储索引)

      1556075073836

3、MEMORY
  • Memory存储引擎将表的数据存放在内存中。
  • 每个MEMORY表实际对应一个磁盘文件,格式是==.frm== ,该文件中只存储表的结构,而其数据文件,都是存储在内存中,这样有利于数据的快速处理,提高整个表的效率
  • 优点:MEMORY 类型的表访问非常地快,因为他的数据是存放在内存中的,并且默认使用HASH索引
  • 缺点:
    • 但是对于内存来说,存储空间是很宝贵的,因此MEMORY 类型的表存储的数据量不能很大
    • 而且服务一旦关闭,表中的数据就会丢失
4、MERGE

MERGE存储引擎是一组MyISAM表的组合,这些MyISAM表必须结构完全相同,MERGE表本身并没有存储数据,对MERGE类型的表可以进行查询、更新、删除操作,这些操作实际上是对内部的MyISAM表进行的。类似于视图(View)

对于MERGE类型表的插入操作,是通过INSERT_METHOD子句定义插入的表,可以有3个不同的值:

  1. 使用FIRST 或 LAST 值使得插入操作被相应地作用在第一或者最后一个表上
  2. 不定义这个子句或者定义为NO,表示不能对这个MERGE表执行插入操作。

可以对MERGE表进行DROP操作,但是这个操作只是删除MERGE表的定义,对内部的表是没有任何影响的。

1556076359503

下面是一个创建和使用MERGE表的示例:

  1. 创建3个测试表 order_1990,order_1991,order_all,其中order_all是前两个表的MERGE表:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    create table order_1990(
    order_id int ,
    order_money double(10,2),
    order_address varchar(50),
    primary key (order_id)
    )engine = myisam default charset=utf8;

    create table order_1991(
    order_id int ,
    order_money double(10,2),
    order_address varchar(50),
    primary key (order_id)
    )engine = myisam default charset=utf8;

    create table order_all(
    order_id int ,
    order_money double(10,2),
    order_address varchar(50),
    primary key (order_id)
    )engine = merge union = (order_1990,order_1991) INSERT_METHOD=LAST default charset=utf8;
  2. 分别向两张表中插入记录

    1
    2
    3
    4
    5
    insert into order_1990 values(1,100.0,'北京');
    insert into order_1990 values(2,100.0,'上海');

    insert into order_1991 values(10,200.0,'北京');
    insert into order_1991 values(11,200.0,'上海');
  3. 查询3张表中的数据:

    • order_1990中的数据:

      1551408083254

    • order_1991中的数据:

      1551408133323

    • order_all中的数据:

      1551408216185

  4. 往order_all中插入一条记录,由于在MERGE表定义时,INSERT_METHOD 选择的是LAST,那么插入的数据会想最后一张表中插入。

    1
    insert into order_all values(100,10000.0,'西安');

    1551408519889

3、存储引擎的选择

在选择存储引擎时,应该根据应用系统的特点选择合适的存储引擎。对于复杂的应用系统,还可以根据实际情况选择多种存储引擎进行组合。

以下是几种常用的存储引擎的使用环境:

  • InnoDB:是Mysql的默认存储引擎,用于事务处理应用程序,支持外键。如果应用对事务的完整性有比较高的要求,在并发条件下要求数据的一致性,数据操作除了插入和查询意外,还包含很多的更新、删除操作,那么InnoDB存储引擎是比较合适的选择。InnoDB存储引擎除了有效的降低由于删除和更新导致的锁定, 还可以确保事务的完整提交和回滚,对于类似于计费系统或者财务系统等对数据准确性要求比较高的系统,InnoDB是最合适的选择。
  • MyISAM : 如果应用是以读操作和插入操作为主,只有很少的更新和删除操作,并且对事务的完整性、并发性要求不是很高,那么选择这个存储引擎是非常合适的
  • MEMORY将所有数据保存在RAM中,在需要快速定位记录和其他类似数据环境下,可以提供几块的访问。MEMORY的缺陷就是对表的大小有限制,太大的表无法缓存在内存中,其次是要确保表的数据可以恢复,数据库异常终止后表中的数据是可以恢复的。MEMORY表通常用于更新不太频繁的小表,用以快速得到访问结果
  • MERGE:用于将一系列等同的MyISAM表以逻辑方式组合在一起,并作为一个对象引用他们。MERGE表的优点在于可以突破对单个MyISAM表的大小限制,并且通过将不同的表分布在多个磁盘上,可以有效的改善MERGE表的访问效率。这对于存储诸如数据仓储等VLDB环境十分合适。

7、优化SQL步骤

在应用的的开发过程中,由于初期数据量小,开发人员写 SQL 语句时更重视功能上的实现,但是当应用系统正式上线后,随着生产数据量的急剧增长,很多 SQL 语句开始逐渐显露出性能问题,对生产的影响也越来越大,此时这些有问题的 SQL 语句就成为整个系统性能的瓶颈,因此我们必须要对它们进行优化,这里将详细介绍在 MySQL 中优化 SQL 语句的方法。

当面对一个有 SQL 性能问题的数据库时,我们应该从何处入手来进行系统的分析,使得能够尽快定位问题 SQL 并尽快解决问题

1、查看SQL执行频率

MySQL 客户端连接成功后,通过 show [session|global] status 命令可以提供服务器状态信息。

1
show [session|global] status;

show [session|global] status 可以根据需要加上参数“session”或者“global”来显示 session 级(当前连接)的计结果和 global 级(自数据库上次启动至今)的统计结果。如果不写,默认使用参数是“session”。

下面的命令显示了当前 session 中整个数据库所有统计参数的值:

1
2
-- 七个下划线
show status like 'Com_______';

1552487172501

下面的命令显示了当前Innodb存储引擎的所有统计参数的值:

1
show status like 'Innodb_rows_%';

image-20210901214257185

以上sql语句查询的是当前连接的相关的状态信息,如果想要查看全局的状态信息,即整一个数据库的状态信息,需要在show与status之间加入 global

1
2
3
show global status like 'Com_______';

show global status like 'Innodb_rows_%';

Com_xxx 表示每个 xxx 语句执行的次数,我们通常比较关心的是以下几个统计参数。

参数 含义
==Com_select== ==执行 select 操作的次数,一次查询只累加 1。==
==Com_insert== ==执行 INSERT 操作的次数,对于批量插入的 INSERT 操作,只累加一次。==
==Com_update== ==执行 UPDATE 操作的次数。==
==Com_delete== ==执行 DELETE 操作的次数。==
Innodb_rows_read select 查询返回的行数。
Innodb_rows_inserted 执行 INSERT 操作插入的行数。
Innodb_rows_updated 执行 UPDATE 操作更新的行数。
Innodb_rows_deleted 执行 DELETE 操作删除的行数。
Connections 试图连接 MySQL 服务器的次数。
Uptime 服务器工作时间。
Slow_queries 慢查询的次数。
  • Com_***:这些参数对于==所有存储引擎的表操作==都会进行累计。
  • Innodb_***:这几个参数==只是针对InnoDB 存储引擎==的,累加的算法也略有不同。

2、定位低效率执行SQL

可以通过以下两种方式定位执行效率较低的 SQL 语句:

  • 慢查询日志:通过慢查询日志定位那些执行效率较低的 SQL 语句,用--log-slow-queries[=file_name]选项启动时,mysqld 写一个包含所有执行时间超过 long_query_time 秒的 SQL 语句的日志文件。具体可以查看日志管理的相关部分。
  • show processlist慢查询日志在查询结束以后才纪录,所以在应用反映执行效率出现问题的时候查询慢查询日志并不能定位问题,可以使用show processlist命令查看当前MySQL在进行的线程,包括线程的状态、是否锁表等,可以实时地查看 SQL 的执行情况,同时对一些锁表操作进行优化。

1556098544349

其中几个表头的相关信息:

  • id:用户登录mysql时,系统分配的”connection_id”**,可以使用函数connection_id()查看**
  • user:显示当前用户。如果不是root,这个命令就只显示用户权限范围的sql语句
  • host:显示这个语句是从哪个ip的哪个端口上发的,可以用来跟踪出现问题语句的用户
  • db:显示这个进程目前连接的是哪个数据库
  • command显示当前连接的执行的命令,一般取值为
    • 休眠(sleep)
    • 查询(query)
    • 连接(connect)等
  • time:显示这个状态持续的时间,单位是秒
  • state:显示使用当前连接的sql语句的状态,很重要的列。
    • state描述的是语句执行中的某一个状态。
    • 一个sql语句,以查询为例,可能需要经过:
      1. copying to tmp table
      2. sorting result
      3. sending data等状态才可以完成
  • info显示这个sql语句,是判断问题语句的一个重要依据

3、explain分析执行计划

通过以上步骤查询到效率低的 SQL 语句后,可以通过 EXPLAIN 或者 DESC 命令获取 MySQL如何执行 SELECT 语句的信息,包括在 SELECT 语句执行过程中表如何连接和连接的顺序。

查询SQL语句的执行计划 :

1
explain select * from tb_item where id = 1;

1552487489859

1
explain select * from tb_item where title = '阿尔卡特 (OT-979) 冰川白 联通3G手机3';

1552487526919

字段 含义
id select查询的序列号,是一组数字,表示的是查询中执行select子句或者是操作表的顺序。(与表结构的执行顺序有关)
select_type 表示 SELECT 的类型,常见的取值有 SIMPLE(简单表,即不使用表连接或者子查询)、PRIMARY(主查询,即外层的查询)、UNION(UNION 中的第二个或者后面的查询语句)、SUBQUERY(子查询中的第一个 SELECT)等
table 输出结果集的表
type 表示表的连接类型,性能由好到差的连接类型为( system —> const —–> eq_ref ——> ref ——-> ref_or_null—-> index_merge —> index_subquery —–> range —–> index ——> all )
possible_keys 表示查询时,可能使用的索引
key 表示实际使用的索引
key_len 索引字段的长度
rows 扫描行的数量
extra 执行情况的说明和描述

现在对以上字段进行相关说明:

1、环境准备

1556122799330

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
CREATE TABLE `t_role` (
`id` varchar(32) NOT NULL,
`role_name` varchar(255) DEFAULT NULL,
`role_code` varchar(255) DEFAULT NULL,
`description` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_role_name` (`role_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `t_user` (
`id` varchar(32) NOT NULL,
`username` varchar(45) NOT NULL,
`password` varchar(96) NOT NULL,
`name` varchar(45) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `user_role` (
`id` int(11) NOT NULL auto_increment ,
`user_id` varchar(32) DEFAULT NULL,
`role_id` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_ur_user_id` (`user_id`),
KEY `fk_ur_role_id` (`role_id`),
CONSTRAINT `fk_ur_role_id` FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `fk_ur_user_id` FOREIGN KEY (`user_id`) REFERENCES `t_user` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=utf8;


insert into `t_user` (`id`, `username`, `password`, `name`) values('1','super','$2a$10$TJ4TmCdK.X4wv/tCqHW14.w70U3CC33CeVncD3SLmyMXMknstqKRe','超级管理员');
insert into `t_user` (`id`, `username`, `password`, `name`) values('2','admin','$2a$10$TJ4TmCdK.X4wv/tCqHW14.w70U3CC33CeVncD3SLmyMXMknstqKRe','系统管理员');
insert into `t_user` (`id`, `username`, `password`, `name`) values('3','itcast','$2a$10$8qmaHgUFUAmPR5pOuWhYWOr291WJYjHelUlYn07k5ELF8ZCrW0Cui','test02');
insert into `t_user` (`id`, `username`, `password`, `name`) values('4','stu1','$2a$10$pLtt2KDAFpwTWLjNsmTEi.oU1yOZyIn9XkziK/y/spH5rftCpUMZa','学生1');
insert into `t_user` (`id`, `username`, `password`, `name`) values('5','stu2','$2a$10$nxPKkYSez7uz2YQYUnwhR.z57km3yqKn3Hr/p1FR6ZKgc18u.Tvqm','学生2');
insert into `t_user` (`id`, `username`, `password`, `name`) values('6','t1','$2a$10$TJ4TmCdK.X4wv/tCqHW14.w70U3CC33CeVncD3SLmyMXMknstqKRe','老师1');


INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('5','学生','student','学生');
INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('7','老师','teacher','老师');
INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('8','教学管理员','teachmanager','教学管理员');
INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('9','管理员','admin','管理员');
INSERT INTO `t_role` (`id`, `role_name`, `role_code`, `description`) VALUES('10','超级管理员','super','超级管理员');


INSERT INTO user_role(id,user_id,role_id) VALUES(NULL, '1', '5'),(NULL, '1', '7'),(NULL, '2', '8'),(NULL, '3', '9'),(NULL, '4', '8'),(NULL, '5', '10') ;
2、explain 之 id

id 字段是 select查询的序列号,是一组数字,表示的是查询中执行select子句或者是操作表的顺序。

id 情况有三种:

  1. id 相同表示加载表的顺序是从上到下

    1
    2
    -- 一次性查询多张表
    explain select * from t_role r, t_user u, user_role ur where r.id = ur.role_id and u.id = ur.user_id;

    1556102471304

    此例中 先执行where 后的第一条语句 r.id = ur.role_id 通过 r.id 关联 ur.role_id 。 而 ur.role_id 的结果建立在 u.id = ur.user_id 的基础之上。

  2. id 不同id值越大,优先级越高,越先被执行。

    1
    2
    -- 采用子查询的方式
    EXPLAIN SELECT * FROM t_role WHERE id = (SELECT role_id FROM user_role WHERE user_id = (SELECT id FROM t_user WHERE username = 'stu1'))

    1556103009534

  3. id 有相同,也有不同,同时存在id相同的可以认为是一组,从上往下顺序执行;在所有的组中,id的值越大,优先级越高,越先执行。

    1
    2
    -- 既查询了多张表,又进行了子查询
    EXPLAIN SELECT * FROM t_role r , (SELECT * FROM user_role ur WHERE ur.`user_id` = '2') a WHERE r.id = a.role_id ;

    1556103294182

3、explain 之 select_type

表示 SELECT 的类型,有哪些:

img

常见的取值,如下表所示:

select_type 含义
SIMPLE 简单的select查询,查询中不包含子查询或者UNION
PRIMARY 查询中若包含任何复杂的子查询,最外层查询标记为该标识
SUBQUERY 在SELECT 或 WHERE 列表中包含了子查询
DERIVED 在FROM 列表中包含的子查询,被标记为 DERIVED(衍生) MYSQL会递归执行这些子查询,把结果放在临时表中
UNION 若第二个SELECT出现在UNION之后,则标记为UNION ; 若UNION包含在FROM子句的子查询中,外层SELECT将被标记为 : DERIVED
UNION RESULT 从UNION表获取结果的SELECT
1、select_type 之 SIMPLE

SIMPLE:简单的select查询,查询中不包含子查询或者UNION

image-20210901220448958

2、select_type 之 PRIMARY

PRIMARY:查询中若包含任何复杂的子查询,最外层查询标记为该标识

3、select_type 之 SUBQUERY

SUBQUERY:在SELECT 或 WHERE 列表中包含了子查询

1
2
3
-- t_user表是查询的最外层的表,所以t_user表是PRIMARY
-- user_role表是在WHERE 语句当中的子查询当中出现的表,所以user_role表是SUBQUERY
explain select * from t_user where id = (select id from user_role where role_id = '9') ;

image-20210901220550625

4、select_type 之 DERIVED

DERIVED:在FROM 列表中包含的子查询,被标记为 DERIVED(衍生) MYSQL会递归执行这些子查询,把结果放在临时表中

1
2
3
-- t_user表是在FROM 列表中包含的子查询当中查询的表,所以t_user表是DERIVED,而生成的临时表就是<derived2>(里面的2表示产生这个临时表的DERIVED的id)
-- 结果从临时表<derived2>当中获取,即临时表<derived2>是最外层的表,因此是PRIMARY
explain select a.* from (select * from t_user where id in ('1', '2')) a;

image-20210901220947928

5、select_type 之 UNION

UNION:若第二个SELECT出现在UNION之后,则标记为UNION ; 若UNION包含在FROM子句的子查询中,外层SELECT将被标记为 : DERIVED

6、select_type 之 UNION RESULT

UNION RESULT:从UNION表获取结果的SELECT

1
2
3
4
-- t_user表是查询的最外层的表,所以t_user表是PRIMARY
-- 在union之后也查询了t_user表,所以在id=2的t_user表是UNION
-- <union1,2>表是连接了id=1与id=2的两张t_user表的结果表,标记为UNION RESULT
explain select * from t_user where id = '1' union select * from t_user where id = '2'

image-20210901221658084

7、其他不常见的类型:select_type 之 DEPENDENT SUBQUERY

DEPENDENT SUBQUERY:在SELECT或WHERE列表中包含了子查询,子查询基于外层

img

dependent subquery 与 subquery 的区别:

  • dependent subquery(依赖子查询) : 子查询结果为 多值
  • subquery (子查询):查询结果为 单值
8、其他不常见的类型:select_type 之 UNCACHEABLE SUBQUREY

UNCACHEABLE SUBQUREY:无法被缓存的子查询

@@ 表示查的环境参数 。没办法缓存

4、explain 之 table

展示这一行的数据是关于哪一张表的

5、explain 之 type

type 显示的是访问类型,是较为重要的一个指标,可取值为:

type 含义
NULL MySQL不访问任何表,索引,直接返回结果
system 表只有一行记录(等于系统表),这是const类型的特例,一般不会出现
const 表示通过索引一次就找到了,const 用于比较primary key 或者 unique 索引。因为只匹配一行数据,所以很快。如将主键置于where列表中,MySQL 就能将该查询转换为一个常量。const于将 “主键” 或 “唯一” 索引的所有部分与常量值进行比较
eq_ref 类似ref,区别在于使用的是唯一索引,使用主键的关联查询,关联查询出的记录只有一条。常见于主键或唯一索引扫描
ref 非唯一性索引扫描,返回匹配某个单独值的所有行。本质上也是一种索引访问,返回所有匹配某个单独值的所有行(多个)
range 只检索给定返回的行,使用一个索引来选择行。 where 之后出现 between , < , > , in 等操作。
index index 与 ALL的区别为 index 类型只是遍历了索引树, 通常比ALL 快, ALL 是遍历数据文件。
all 将遍历全表以找到匹配的行

结果值从最好到最坏以此是:

1
2
3
4
NULL > system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL


system > const > eq_ref > ref > range > index > ALL

==一般来说, 我们需要保证查询至少达到 range 级别, 最好达到ref 。==

相关补充:

type 含义
index_merge 在查询过程中需要多个索引组合使用,通常出现在有 or 的关键字的sql中
ref_or_null 对于某个字段既需要关联条件,也需要null值得情况下。查询优化器会选择用ref_or_null连接查询。
index_subquery 利用索引来关联子查询,不再全表扫描。
unique_subquery 该联接类型类似于index_subquery。 子查询中的唯一索引
1、type 之 NULL

NULL:MySQL不访问任何表,索引,直接返回结果

image-20210901223108340

2、type 之 system

system:表只有一行记录(等于系统表),这是const类型的特例,一般不会出现

image-20210901223220731

3、type 之 const

const:表示通过索引一次就找到了const 用于比较primary key 或者 unique 索引。因为只匹配一行数据,所以很快。如将主键置于where列表中,MySQL 就能将该查询转换为一个常量。const于将 “主键” 或 “唯一” 索引的所有部分与常量值进行比较

通过比较primary key

image-20210901223310082

通过比较unique 索引

image-20210901223455304

4、type 之 eq_ref

eq_ref:类似ref,区别在于使用的是唯一索引,使用主键的关联查询,关联查询出的记录只有一条。常见于主键或唯一索引扫描

image-20210901223635631

5、type 之 ref

ref:非唯一性索引扫描,返回匹配某个单独值的所有行。本质上也是一种索引访问,返回所有匹配某个单独值的所有行(多个)

image-20210901223819993

image-20210901223749200

6、type 之 range

range:只检索给定返回的行,使用一个索引来选择行where 之后出现 between , < , > , in 等操作。

7、type 之 index

index:index 与 ALL的区别为 index 类型只是遍历了索引树, 通常比ALL 快, ALL 是遍历数据文件。

查询的是id主键,mysql的主键默认有着主键索引:

image-20210901223945702

8、type 之 all

all:将遍历全表以找到匹配的行

image-20210901223912058

9、补充:type 之 index_merge

index_merge:在查询过程中需要多个索引组合使用,通常出现在有 or 的关键字的sql中

img

10、补充:type 之 ref_or_null

ref_or_null:对于某个字段既需要关联条件,也需要null值得情况下。查询优化器会选择用ref_or_null连接查询。

img

11、补充:type 之 index_subquery

index_subquery:利用索引来关联子查询,不再全表扫描。

img

img

img

12、 补充:type 之 unique_subquery

unique_subquery:该联接类型类似于index_subquery。 子查询中的唯一索引

img

6、explain 之 key
  • possible_keys:显示==可能==应用在这张表的索引, 一个或多个。
  • key:==实际使用==的索引, 如果为NULL, 则没有使用索引。
  • key_len:表示==索引中使用的字节数==, 该值为索引字段最大可能长度,并非实际使用长度,即key_len是根据表定义计算而得,不是通过表内检索出的
    • 在不损失精确性的前提下, 长度越短越好,越短执行效率越高。
1、key_len的长度如何计算
1
EXPLAIN SELECT * FROM emp WHERE emp.deptno=109 AND emp.`ename`='AvDEjl'

img

如何计算

img

总结一下:char(30) utf8 –> key_len = 30*3 +1 表示:

  • utf8 格式需要 *3 (跟数据类型有关)
  • 允许为 NULL +1 ,不允许 +0
  • 动态类型 +2 (动态类型包括 : varchar , detail text() 截取字符窜)

img

  • 第一组:key_len = deptno(int) + null + ename(varchar(20) * 3 + 动态) = 4 + 1+ 20 * 3 + 2= 67
  • 第二组:key_len = deptno(int) + null = 4 + 1 = 5
7、explain 之 rows

rows列显示MySQL认为它执行查询时必须检查的行数。 越少越好

8、explain 之 extra

其他的额外的执行计划信息,在该列展示 。

extra 含义
using filesort 说明mysql会对数据使用一个外部的索引排序,而不是按照表内的索引顺序进行读取, 称为 “文件排序”, 效率低。
using temporary 使用了临时表保存中间结果,MySQL在对查询结果排序时使用临时表。常见于 order by 和 group by; 效率低
using index 表示相应的select操作使用了覆盖索引, 避免访问表的数据行, 效率不错。
Using where 表明使用了where过滤
using join buffer 使用了连接缓存:
impossible where where子句的值总是false,不能用来获取任何元组
select tables optimized away 在没有GROUPBY子句的情况下,基于索引优化MIN/MAX操作或者
distinct 优化distinct操作,在找到第一匹配的元祖后即停止找同样值的动作

如果出现的是前面两个,就需要考虑优化了,因为前面那两个是非常耗费性能的。如果出现的是最后一个,则需要保持,因为使用到了索引,性能较高。

1、extra 之 using filesort(重要)

using filesort:说明mysql会对数据使用一个外部的索引排序,而不是按照表内的索引顺序进行读取, 称为 “文件排序”;效率低。

image-20210901224456653

image-20210901224517422

image-20210901224657926

2、extra 之 using temporary(重要)

using temporary:使用了临时表保存中间结果,MySQL在对查询结果排序时使用临时表。常见于 order by 和 group by;效率低

image-20210901224752479

3、extra 之 using index(重要)

using index:表示相应的select操作使用了覆盖索引,避免访问表的数据行,效率不错。

  • 如果同时出现using where,表明索引被用来执行索引键值的查找;
  • 如果没有同时出现using where,表明索引只是用来读取数据而非利用索引执行查找。

**覆盖索引(Covering Index)**:

索引是高效找到行的一个方法,但是一般数据库也能使用索引找到一个列的数据,因此它不必读取整个行。毕竟索引叶子节点存储了它们索引的数据;当能通过读取索引就可以得到想要的数据,那就不需要读取行了。

  1. ==一个索引==
  2. ==包含了(或覆盖了)[select子句]与查询条件[Where子句]中==
  3. ==所有需要的字段就叫做覆盖索引==。

上句理解:

1
select id , name from t_xxx where age=18;

有一个组合索引 idx_id_name_age_xxx 包含了(覆盖了),id,name,age三个字段。查询时直接将建立了索引的列读取出来了,而不需要去查找所在行的其他数据。所以很高效。

(个人认为:在数据量较大,固定字段查询情况多时可以使用这种方法。)

注意:

  • *如果要使用覆盖索引,一定要注意select列表中只取出需要的列,不可select **
  • 因为如果将所有字段一起做索引会导致索引文件过大,查询性能下降
4、extra 之 using join buffer(了解)

using join buffer:使用了连接缓存

img

出现在当两个连接时:

  • 驱动表(被连接的表,left join 左边的表。inner join 中数据少的表) 没有索引的情况下,给驱动表建立索引可解决此问题。且 type 将改变成 ref
5、extra 之 impossible where(了解)

impossible where:where子句的值总是false,不能用来获取任何元组

img

4、show profile分析SQL

Mysql从5.0.37版本开始增加了对 show profiles 和 show profile 语句的支持。show profiles 能够在做SQL优化时帮助我们了解时间都耗费到哪里去了。

通过 have_profiling 参数,能够看到当前MySQL是否支持profile

1552488401999

默认profiling是关闭的,可以通过set语句在Session级别开启profiling

1552488372405

1
2
-- 开启profiling 开关;
set profiling = 1;

通过profile,我们能够更清楚地了解SQL执行的过程。

首先,我们可以执行一系列的操作,如下图所示:

1
2
3
4
5
6
7
8
9
show databases;

use db01;

show tables;

select * from tb_item where id < 5;

select count(*) from tb_item;

执行完上述命令之后,再执行show profiles 指令, 来查看SQL语句执行的耗时:

1552489017940

通过show profile for query query_id 语句可以查看到该SQL执行过程中每个线程的状态和消耗的时间:

1552489053763

注意:Sending data 状态表示==MySQL线程开始访问数据行并把结果返回给客户端==,而不仅仅是返回个客户端。由于在Sending data状态下,MySQL线程往往需要做大量的磁盘读取操作,所以经常是整各查询中耗时最长的状态。

在获取到最消耗时间的线程状态后,MySQL支持进一步选择allcpublock iocontext switchpage faults等明细类型类查看MySQL在使用什么资源上耗费了过高的时间。

例如,选择查看CPU的耗费时间 :

1552489671119

字段 含义
Status sql 语句执行的状态
Duration sql 执行过程中每一个步骤的耗时
CPU_user 当前用户占有的cpu
CPU_system 系统占有的cpu

5、trace分析优化器执行计划

MySQL5.6提供了对SQL的跟踪trace,通过trace文件能够进一步了解为什么优化器选择A计划,而不是选择B计划。

打开trace,设置格式为 JSON,并设置trace最大能够使用的内存大小,避免解析过程中因为默认内存过小而不能够完整展示

1
2
SET optimizer_trace="enabled=on",end_markers_in_json=on;
set optimizer_trace_max_mem_size=1000000;

执行SQL语句 :

1
select * from tb_item where id < 4;

最后, 检查information_schema.optimizer_trace就可以知道MySQL是如何执行SQL的 :

1
select * from information_schema.optimizer_trace\G;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
*************************** 1. row ***************************
QUERY: select * from tb_item where id < 4
TRACE: {
"steps": [
{
"join_preparation": {
"select#": 1,
"steps": [
{
"expanded_query": "/* select#1 */ select `tb_item`.`id` AS `id`,`tb_item`.`title` AS `title`,`tb_item`.`price` AS `price`,`tb_item`.`num` AS `num`,`tb_item`.`categoryid` AS `categoryid`,`tb_item`.`status` AS `status`,`tb_item`.`sellerid` AS `sellerid`,`tb_item`.`createtime` AS `createtime`,`tb_item`.`updatetime` AS `updatetime` from `tb_item` where (`tb_item`.`id` < 4)"
}
] /* steps */
} /* join_preparation */
},
{
"join_optimization": {
"select#": 1,
"steps": [
{
"condition_processing": {
"condition": "WHERE",
"original_condition": "(`tb_item`.`id` < 4)",
"steps": [
{
"transformation": "equality_propagation",
"resulting_condition": "(`tb_item`.`id` < 4)"
},
{
"transformation": "constant_propagation",
"resulting_condition": "(`tb_item`.`id` < 4)"
},
{
"transformation": "trivial_condition_removal",
"resulting_condition": "(`tb_item`.`id` < 4)"
}
] /* steps */
} /* condition_processing */
},
{
"table_dependencies": [
{
"table": "`tb_item`",
"row_may_be_null": false,
"map_bit": 0,
"depends_on_map_bits": [
] /* depends_on_map_bits */
}
] /* table_dependencies */
},
{
"ref_optimizer_key_uses": [
] /* ref_optimizer_key_uses */
},
{
"rows_estimation": [
{
"table": "`tb_item`",
"range_analysis": {
"table_scan": {
"rows": 9816098,
"cost": 2.04e6
} /* table_scan */,
"potential_range_indices": [
{
"index": "PRIMARY",
"usable": true,
"key_parts": [
"id"
] /* key_parts */
}
] /* potential_range_indices */,
"setup_range_conditions": [
] /* setup_range_conditions */,
"group_index_range": {
"chosen": false,
"cause": "not_group_by_or_distinct"
} /* group_index_range */,
"analyzing_range_alternatives": {
"range_scan_alternatives": [
{
"index": "PRIMARY",
"ranges": [
"id < 4"
] /* ranges */,
"index_dives_for_eq_ranges": true,
"rowid_ordered": true,
"using_mrr": false,
"index_only": false,
"rows": 3,
"cost": 1.6154,
"chosen": true
}
] /* range_scan_alternatives */,
"analyzing_roworder_intersect": {
"usable": false,
"cause": "too_few_roworder_scans"
} /* analyzing_roworder_intersect */
} /* analyzing_range_alternatives */,
"chosen_range_access_summary": {
"range_access_plan": {
"type": "range_scan",
"index": "PRIMARY",
"rows": 3,
"ranges": [
"id < 4"
] /* ranges */
} /* range_access_plan */,
"rows_for_plan": 3,
"cost_for_plan": 1.6154,
"chosen": true
} /* chosen_range_access_summary */
} /* range_analysis */
}
] /* rows_estimation */
},
{
"considered_execution_plans": [
{
"plan_prefix": [
] /* plan_prefix */,
"table": "`tb_item`",
"best_access_path": {
"considered_access_paths": [
{
"access_type": "range",
"rows": 3,
"cost": 2.2154,
"chosen": true
}
] /* considered_access_paths */
} /* best_access_path */,
"cost_for_plan": 2.2154,
"rows_for_plan": 3,
"chosen": true
}
] /* considered_execution_plans */
},
{
"attaching_conditions_to_tables": {
"original_condition": "(`tb_item`.`id` < 4)",
"attached_conditions_computation": [
] /* attached_conditions_computation */,
"attached_conditions_summary": [
{
"table": "`tb_item`",
"attached": "(`tb_item`.`id` < 4)"
}
] /* attached_conditions_summary */
} /* attaching_conditions_to_tables */
},
{
"refine_plan": [
{
"table": "`tb_item`",
"access_type": "range"
}
] /* refine_plan */
}
] /* steps */
} /* join_optimization */
},
{
"join_execution": {
"select#": 1,
"steps": [
] /* steps */
} /* join_execution */
}
] /* steps */
}

8、索引的使用

索引是数据库优化最常用也是最重要的手段之一,通过索引通常可以帮助用户解决大多数的MySQL的性能优化问题。

1、验证索引提升查询效率

在我们准备的表结构tb_item 中, 一共存储了 300 万记录;

1、根据ID查询
1
select * from tb_item where id = 1999\G;

查询速度很快, 接近0s , 主要的原因是因为id为主键, 有索引;

image-20210901225426139

查看SQL语句的执行计划:

image-20210901225525932

2、根据 title 进行精确查询
1
select * from tb_item where title = 'iphoneX 移动3G 32G941'\G; 

image-20210901225650475

查看SQL语句的执行计划:

image-20210901225744561

处理方案 , 针对title字段, 创建索引:

1
create index idx_item_title on tb_item(title);

image-20210901225830193

索引创建完成之后,再次进行查询:

image-20210901225903333

通过explain , 查看执行计划,执行SQL时使用了刚才创建的索引:

image-20210901225936532

2、索引的使用

1、准备环境
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
create table `tb_seller` (
`sellerid` varchar (100),
`name` varchar (100),
`nickname` varchar (50),
`password` varchar (60),
`status` varchar (1),
`address` varchar (100),
`createtime` datetime,
primary key(`sellerid`)
)engine=innodb default charset=utf8mb4;

insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('alibaba','阿里巴巴','阿里小店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('baidu','百度科技有限公司','百度小店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('huawei','华为科技有限公司','华为小店','e10adc3949ba59abbe56e057f20f883e','0','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('itcast','传智播客教育科技有限公司','传智播客','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('itheima','黑马程序员','黑马程序员','e10adc3949ba59abbe56e057f20f883e','0','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('luoji','罗技科技有限公司','罗技小店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('oppo','OPPO科技有限公司','OPPO官方旗舰店','e10adc3949ba59abbe56e057f20f883e','0','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('ourpalm','掌趣科技股份有限公司','掌趣小店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('qiandu','千度科技','千度小店','e10adc3949ba59abbe56e057f20f883e','2','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('sina','新浪科技有限公司','新浪官方旗舰店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('xiaomi','小米科技','小米官方旗舰店','e10adc3949ba59abbe56e057f20f883e','1','西安市','2088-01-01 12:00:00');
insert into `tb_seller` (`sellerid`, `name`, `nickname`, `password`, `status`, `address`, `createtime`) values('yijia','宜家家居','宜家家居旗舰店','e10adc3949ba59abbe56e057f20f883e','1','北京市','2088-01-01 12:00:00');

-- 创建name,status,address的复合索引
create index idx_seller_name_sta_addr on tb_seller(name,status,address);
2、避免索引失效
1、全值匹配 ,对索引中所有列都指定具体值

该情况下,索引生效,执行效率高。

1
2
-- 对索引中所有列(name,status,address)都指定具体值
explain select * from tb_seller where name='小米科技' and status='1' and address='北京市'\G;

1556170997921

2、最左前缀法则

如果索引了多列,即复合索引,要遵守最左前缀法则。指的是查询从索引的最左前列开始,并且不跳过索引中的列

注意:

  • 创建的复合索引为name,status,address的复合索引,在该表中会创建三个索引:
    • idx_seller_name:name的索引——对应的索引长度为:403
    • idx_seller_name_sta:name与status的索引——对应的索引长度为:410
    • idx_seller_name_sta_addr:name、status与address的索引——对应的索引长度为:813
  • 匹配最左前缀法则,走索引:

    1556171348995

  • 把name放在最后面,走索引:(与where之后的条件先后顺序没有关系,只与查询条件有无有关)

    • and 忽略左右关系。既即使没有没有按顺序 由于优化器的存在,会自动优化。

    image-20210901141116945

  • 违法最左前缀法则 , 索引失效:

    1556171428140

  • 如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效:

    1556171662203

节点比较排序是先比较第一列,第一列相同就比较第二列,接着第三列,以此类推。所以不使用第一列的话,后面就乱序了,走不了索引。

举一个简单的例子:走楼层

  • 对应的三个索引就是上层楼:
    • name——第一层
    • name&status——第二层
    • name$status$address——第三层
  • 匹配最左前缀法则,走索引——成功走到楼顶
  • 违法最左前缀法则 , 索引失效
    • 只是使用了status或者address:未走第一层楼就想走到第二层楼或者第三层楼——失败
    • 同时使用了status与address:也是一样,走第一层楼就想走到第二层楼和第三层楼——失败
  • 如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效
    • 同时使用了name与address:走了第一层,但是没走第二层就想到第三层楼——第一层楼成功,第二第三层楼失败
3、范围查询右边的列,不能使用索引

1556172256791

根据前面的两个字段name , status 查询是走索引的, 但是最后一个条件address 没有用到索引。

4、不要在索引列上进行运算操作, 索引将失效

1556172813715

5、字符串不加单引号,造成索引失效

1556172967493

由于在查询时,没有对字符串加单引号,MySQL的查询优化器,会自动的进行类型转换(隐式类型转换),造成索引失效(底层对索引进行了运算操作)。

6、尽量使用覆盖索引,避免select *

尽量使用覆盖索引(只访问索引的查询(索引列完全包含查询列)),减少select * 。

1556173928299

如果查询列,超出索引列,也会降低性能。

1556173986068

注意:

  • using index:使用覆盖索引的时候就会出现
  • using where:在查找使用索引的情况下,需要回表去查询所需的数据
  • using index condition:查找使用了索引,但是索引只是记录当前索引值的数据,需要回表查询其他数据
    (回调查询)
  • using index ; using where:查找使用了索引,但是需要的数据都在索引列中能找到,所以不需要回表查询数据
7、用or分割开的条件, 如果or前的条件中的列有索引,而后面的列中没有索引,那么涉及的索引都不会被用到

示例,name字段是索引列 , 而createtime不是索引列,中间是or进行连接是不走索引的 :

1
explain select * from tb_seller where name='黑马程序员' or createtime = '2088-01-01 12:00:00'\G;	

1556174994440

8、以%开头的Like模糊查询,索引失效

如果仅仅是尾部模糊匹配,索引不会失效。如果是头部模糊匹配,索引失效。

1556175114369

解决方案 :

通过覆盖索引来解决

1556247686483

9、如果MySQL评估使用索引比全表更慢,则不使用索引

1556175445210

10、is NULL , is NOT NULL 有时索引失效

1556180634889

mysql底层会判定该字段中的数据大部分是null还是not null:

  • 如果大部分是not null,则使用is NOT NULL 的时候查询的是大部分数据,mysql底层会评估使用索引比全表更慢,则不使用索引,使用全表扫描;同理,如果使用的是is NULL的话,查询的是少量数据,这时候mysql使用索引查询的效率会比全表扫描高,使用mysql会使用索引。
  • 而且因为这里是select * 查询全部数据,如果使用了索引,那么还需要回表查询索引匹配的其他数据,速度上还不如直接全表扫描。
11、in 走索引, not in 索引失效

1556249602732

12、 单列索引和复合索引

尽量使用复合索引,而少使用单列索引 。

创建复合索引:

1
2
3
4
5
6
create index idx_name_sta_address on tb_seller(name, status, address);

-- 就相当于创建了三个索引 :
-- name
-- name + status
-- name + status + address

创建单列索引:

1
2
3
create index idx_seller_name on tb_seller(name);
create index idx_seller_status on tb_seller(status);
create index idx_seller_address on tb_seller(address);
  • 数据库会选择一个最优的索引(辨识度最高索引)来使用,并不会使用全部索引 。
    • 最优索引:表中的数据辨识度,辨识度越高,索引越优。(当查询的常量在表中只有一条数据,此时的辨识度是很高的)
  • 3个单列索引对应3个b+tree数据结构,通过索引查找只能以一个b+tree为标准来查,所以就算可能涉及到多个索引,但是只能使用一个索引。
13、mysql 在使用不等于(!= 或者<>)的时候无法使用索引会导致全表扫描

索引:

  • idx_nameAgeJob
  • idx_name

使用 != 和<>的字段索引失效( != 针对数值类型。 针对字符类型 != 针对数值类型)

前提 where and 后的字段在混合索引中的位置比当前字段靠后 where age != 10 and name=’xxx’,这种情况下,mysql自动优化,将 name=’xxx’ 放在 age !=10 之前,name 依然能使用索引。只是 age 的索引失效)

img

3、一般性建议
  • 对于单键索引,尽量选择针对当前query过滤性更好的索引
  • 在选择组合索引的时候,当前Query中过滤性最好的字段在索引字段顺序中,位置越靠前越好。(避免索引过滤性好的索引失效)
  • 在选择组合索引的时候,尽量选择可以能够包含当前query中的where字句中更多字段的索引
  • 尽可能通过分析统计信息和调整query的写法来达到选择合适索引的目的
4、查看索引使用情况
1
2
3
4
-- 查看当前会话的索引情况
show status like 'Handler_read%';
-- 查看全局的索引情况
show global status like 'Handler_read%';

1552885364563

  • Handler_read_first索引中第一条被读的次数。如果较高,表示服务器正执行大量全索引扫描(这个值越低越好)。
  • Handler_read_key如果索引正在工作,这个值代表一个行被索引值读的次数,如果值越低,表示索引得到的性能改善不高,因为索引不经常使用(这个值越高越好)
  • Handler_read_next按照键顺序读下一行的请求数。如果你用范围约束或如果执行索引扫描来查询索引列,该值增加。
  • Handler_read_prev按照键顺序读前一行的请求数。该读方法主要用于优化ORDER BY … DESC
  • Handler_read_rnd根据固定位置读一行的请求数。如果你正执行大量查询并需要对结果进行排序该值较高。你可能使用了大量需要MySQL扫描整个表的查询或你的连接没有正确使用键。这个值较高,意味着运行效率低,应该建立索引来补救
  • Handler_read_rnd_next在数据文件中读下一行的请求数。如果你正进行大量的表扫描,该值较高。通常说明你的表索引不正确或写入的查询没有利用索引

9、SQL优化

什么时候需要用到SQL优化?

步骤:

  1. 查询优化
  2. 观察,至少跑1天,看看生产的慢SQL情况。
  3. 开启慢查询日志,设置阙值,比如超过5秒钟的就是慢SQL,并将它抓取出来。
  4. explain+慢SQL分析
  5. show profile
  6. 运维经理or DBA,进行SQL数据库服务器的参数调优。

优化原则:小表驱动大表(原理:RBO)

1
2
3
4
5
select * from A where id in (select id from B)

-- 等价于:
for select id from B
for select * from A where A.id = B.id

当B表的数据集必须小于A表的数据集时,用in优于exists。

1
2
3
4
5
select * from A where exists (select 1 from B where B.id = A.id)

-- 等价于:
for select * from A
for select * from B where B.id = A.id

当A表的数据集系小于B表的数据集时,用exists优于in。

注意:A表与B表的ID字段应建立索引。

  • EXISTS

    1
    SELECT .. FROM table WHERE EXISTS (subquery)

    该语法可以理解为:将主查询的数据,放到子查询中做条件验证,根据验证结果(TRUE或FALSE)来决定主查询的数据结果是否得以保留。

  • 提示:

    1. *EXISTS (subquery)只返回TRUE或FALSE,因此子查询中的SELECT 也可以是SELECT 1或select’X’,官方说法是实际执行时会忽略SELECT清单,因此没有区别
    2. EXISTS子查询的实际执行过程可能经过了优化而不是我们理解上的逐条对比,如果担忧效率问题,可进行实际检验以确定是否有效率问题。
    3. EXISTS子查询往往也可以用条件表达式、其他子查询或者JOIN来替代,何种最优需要具体问题具体分析

1、大批量插入数据

环境准备 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- 两个表示一模一样的,一个用来测试有顺序的插入,一个用来测试没有顺序的插入
CREATE TABLE `tb_user_1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(45) NOT NULL,
`password` varchar(96) NOT NULL,
`name` varchar(45) NOT NULL,
`birthday` datetime DEFAULT NULL,
`sex` char(1) DEFAULT NULL,
`email` varchar(45) DEFAULT NULL,
`phone` varchar(45) DEFAULT NULL,
`qq` varchar(32) DEFAULT NULL,
`status` varchar(32) NOT NULL COMMENT '用户状态',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

CREATE TABLE `tb_user_2` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(45) NOT NULL,
`password` varchar(96) NOT NULL,
`name` varchar(45) NOT NULL,
`birthday` datetime DEFAULT NULL,
`sex` char(1) DEFAULT NULL,
`email` varchar(45) DEFAULT NULL,
`phone` varchar(45) DEFAULT NULL,
`qq` varchar(32) DEFAULT NULL,
`status` varchar(32) NOT NULL COMMENT '用户状态',
`create_time` datetime NOT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_user_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;

当使用load 命令导入数据的时候,适当的设置可以提高导入的效率。

1556269346488

对于 InnoDB 类型的表,有以下几种方式可以提高导入的效率:

1、主键顺序插入

因为InnoDB类型的表是按照主键的顺序保存的,所以将导入的数据按照主键的顺序排列,可以有效的提高导入数据的效率(底层使用的是B+树)。如果InnoDB表没有主键,那么系统会自动默认创建一个内部列作为主键,所以如果可以给表创建一个主键,将可以利用这点,来提高导入数据的效率。

脚本文件介绍:

  • sql1.log —-> 主键有序
  • sql2.log —-> 主键无序

插入ID顺序排列数据:

1
2
-- 记忆:load(加载) data(数据) 到本地文件 + 本地文件地址 into table(到哪一张表) + 表名 fields (属性) 之间的 terminated(分隔符)by 确定的分隔符 + lines(行)之间的 terminated(分隔符)by 确定的分隔符
load data local infile '/root/sql1.log' into table 'tb_user_1' fields terminated by ',' lines terminated by '\n';

1555771750567

插入ID无序排列数据:

1555771959734

2、关闭唯一性校验

如果保证自己数据没问题,在导入数据前执行 SET UNIQUE_CHECKS=0,关闭唯一性校验,在导入结束后执行SET UNIQUE_CHECKS=1,恢复唯一性校验,可以提高导入的效率。

1555772132736

3、手动提交事务

如果应用使用自动提交的方式,建议在导入前执行 SET AUTOCOMMIT=0,关闭自动提交,导入结束后再执行 SET AUTOCOMMIT=1,打开自动提交,也可以提高导入的效率。

1555772351208

2、优化 insert 语句

当进行数据的insert操作的时候,可以考虑采用以下几种优化方案:

  1. 如果需要同时对一张表插入很多行数据时,应该尽量使用多个值表的insert语句,这种方式将大大的缩减客户端与数据库之间的连接、关闭等消耗。使得效率比分开执行的单个insert语句快。

    示例, 原始方式为:

    1
    2
    3
    insert into tb_test values(1,'Tom');
    insert into tb_test values(2,'Cat');
    insert into tb_test values(3,'Jerry');

    优化后的方案为 :

    1
    insert into tb_test values(1,'Tom'),(2,'Cat'),(3,'Jerry');
  2. 在事务中进行数据插入

    1
    2
    3
    4
    5
    start transaction;
    insert into tb_test values(1,'Tom');
    insert into tb_test values(2,'Cat');
    insert into tb_test values(3,'Jerry');
    commit;
  3. 数据有序插入

    1
    2
    3
    4
    5
    insert into tb_test values(4,'Tim');
    insert into tb_test values(1,'Tom');
    insert into tb_test values(3,'Jerry');
    insert into tb_test values(5,'Rose');
    insert into tb_test values(2,'Cat');

    优化后

    1
    2
    3
    4
    5
    insert into tb_test values(1,'Tom');
    insert into tb_test values(2,'Cat');
    insert into tb_test values(3,'Jerry');
    insert into tb_test values(4,'Tim');
    insert into tb_test values(5,'Rose');

3、优化 order by 语句

1、环境准备
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CREATE TABLE `emp` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`age` int(3) NOT NULL,
`salary` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

insert into `emp` (`id`, `name`, `age`, `salary`) values('1','Tom','25','2300');
insert into `emp` (`id`, `name`, `age`, `salary`) values('2','Jerry','30','3500');
insert into `emp` (`id`, `name`, `age`, `salary`) values('3','Luci','25','2800');
insert into `emp` (`id`, `name`, `age`, `salary`) values('4','Jay','36','3500');
insert into `emp` (`id`, `name`, `age`, `salary`) values('5','Tom2','21','2200');
insert into `emp` (`id`, `name`, `age`, `salary`) values('6','Jerry2','31','3300');
insert into `emp` (`id`, `name`, `age`, `salary`) values('7','Luci2','26','2700');
insert into `emp` (`id`, `name`, `age`, `salary`) values('8','Jay2','33','3500');
insert into `emp` (`id`, `name`, `age`, `salary`) values('9','Tom3','23','2400');
insert into `emp` (`id`, `name`, `age`, `salary`) values('10','Jerry3','32','3100');
insert into `emp` (`id`, `name`, `age`, `salary`) values('11','Luci3','26','2900');
insert into `emp` (`id`, `name`, `age`, `salary`) values('12','Jay3','37','4500');

create index idx_emp_age_salary on emp(age,salary);
2、两种排序方式
1、filesort 排序

第一种是通过对返回数据进行排序,也就是通常说的 filesort 排序,所有不是通过索引直接返回排序结果的排序都叫 FileSort 排序。

1556335817763

2、using index

第二种通过有序索引顺序扫描直接返回有序数据,这种情况即为 using index,不需要额外排序,操作效率高。

1556335866539

多字段排序

1556336352061

了解了MySQL的排序方式,优化目标就清晰了:

  • 尽量减少额外的排序,通过索引直接返回有序数据。
  • where 条件和Order by 使用相同的索引,并且Order By 的顺序和索引顺序相同,
  • 并且Order by 的字段都是升序,或者都是降序。否则肯定需要额外的操作,这样就会出现FileSort。
3、Filesort 的优化

通过创建合适的索引,能够减少 Filesort 的出现,但是在某些情况下,条件限制不能让Filesort消失,那就需要加快 Filesort的排序操作。

对于Filesort , MySQL 有两种排序算法:

  1. 两次扫描算法:MySQL4.1 之前,使用该方式排序。首先根据条件取出排序字段和行指针信息,然后在排序区 sort buffer 中排序,如果sort buffer不够,则在临时表 temporary table 中存储排序结果(一次扫描)。完成排序之后,再根据行指针回表读取记录(两次扫描),该操作可能会导致大量随机I/O操作。
    • 多路排序需要借助 磁盘来进行排序。所以 取数据,排好了取数据。两次 io操作。比较慢
  2. 一次扫描算法:一次性取出满足条件的所有字段,然后在排序区 sort buffer 中排序后直接输出结果集排序时内存开销较大,但是排序效率比两次扫描算法要高
    • 单路排序 ,将排好的数据存在内存中,省去了一次 io 操作,所以比较快,但是需要内存空间足够。
    • 但是用单路也有它的问题:
      • 在sort_buffer中,方法B比方法A要多占用很多空间,因为方法B是把所有字段都取出,所以有可能取出的数据的总大小超出了sort_buffer的容量,导致每次只能取sort_buffer容量大小的数据,进行排序(创建tmp文件,多路合并),排完再取取sort_buffer容量大小,再排……从而多次I/O。
      • 本来想省一次I/O操作,反而导致了大量的I/O操作,反而得不偿失。

MySQL 通过比较系统变量 max_length_for_sort_data 的大小和==Query语句取出的字段总大小==, 来判定是否那种排序算法,如果max_length_for_sort_data 更大,那么使用第二种优化之后的算法;否则使用第一种。

可以适当提高 sort_buffer_size 和 max_length_for_sort_data 系统变量,来增大排序区的大小,提高排序的效率。

1556338367593

4、优化 group by 语句

由于GROUP BY 实际上也同样会进行排序操作,而且与ORDER BY 相比,GROUP BY 主要只是多了排序之后的分组操作。当然,如果在分组的时候还使用了其他的一些聚合函数,那么还需要一些聚合函数的计算。所以,在GROUP BY 的实现过程中,与 ORDER BY 一样也可以利用到索引。

如果查询包含 group by 但是用户想要避免排序结果的消耗, 则可以执行order by null 禁止排序。如下:

1
2
3
drop index idx_emp_age_salary on emp;

explain select age,count(*) from emp group by age;

1556339573979

优化后:

1
explain select age,count(*) from emp group by age order by null;

1556339633161

从上面的例子可以看出,第一个SQL语句需要进行”filesort”,而第二个SQL由于order by null 不需要进行 “filesort”, 而上文提过Filesort往往非常耗费时间。

创建索引:

1
create index idx_emp_age_salary on emp(age,salary);

1556339688158

一些建议:

  • group by实质是先排序后进行分组,遵照索引建的最佳左前缀
  • 当无法使用索引列,增大max_length_for_sort_data参数的设置+增大sort_buffer_size参数的设置
  • where高于having,能写在where限定的条件就不要去having限定了。

5、优化嵌套查询

1、使用join替代子查询

Mysql4.1版本之后,开始支持SQL的子查询。这个技术可以使用SELECT语句来创建一个单列的查询结果,然后把这个结果作为过滤条件用在另一个查询中。使用子查询可以一次性的完成很多逻辑上需要多个步骤才能完成的SQL操作,同时也可以避免事务或者表锁死,并且写起来也很容易。但是,有些情况下,子查询是可以被更高效的连接(JOIN)替代。

示例 ,查找有角色的所有的用户信息:

1
explain select * from t_user where id in (select user_id from user_role );

执行计划为:

1556359399199

优化后:

1
explain select * from t_user u , user_role ur where u.id = ur.user_id;

1556359482142

连接(Join)查询之所以更有效率一些 ,是因为MySQL不需要在内存中创建临时表来完成这个逻辑上需要两个步骤的查询工作。

2、where 之后使用的是 用in 还是 exists
1、实验

1、有索引&大表驱动小表

img

img

img

img

2、有索引&小表驱动大表

img

结论:有索引 小驱动大表 性能优于 大表驱动小表

3、无索引&小表驱动大表

img

img

img

4、无索引&大表驱动小表

img

img

3、结论
  • 有索引的情况下 用 inner join 是最好的 其次是 in ,exists最糟糕
  • 无索引的情况下用:
    • 小表驱动大表 因为join 方式需要distinct ,没有索引distinct消耗性能较大
    • 所以 exists性能最佳 in其次 join性能最差
  • 无索引的情况下大表驱动小表
    • in 和 exists 的性能应该是接近的 都比较糟糕 exists稍微好一点 超不过5% 但是inner join 优于使用了 join buffer 所以快很多
    • 如果left join 则最慢

6、优化OR条件

对于包含OR的查询子句,如果要利用索引,则OR之间的每个条件列都必须用到索引 , 而且==不能使用到复合索引,要使用单列索引==; 如果没有索引,则应该考虑增加索引。

获取 emp 表中的所有的索引:

1556354464657

示例:

1
explain select * from emp where id = 1 or age = 30;

1556354887509

1556354920964

建议使用 union 替换 or:

1556355027728

image-20210901151907930

image-20210901151933870

我们来比较下重要指标,发现主要差别是 type 和 ref 这两项

type 显示的是访问类型,是较为重要的一个指标,结果值从好到坏依次是:

1
system > const > eq_ref > ref > fulltext > ref_or_null  > index_merge > unique_subquery > index_subquery > range > index > ALL

UNION 语句的 type 值为 ref,OR 语句的 type 值为 range,可以看到这是一个很明显的差距

UNION 语句的 ref 值为 const,OR 语句的 type 值为 null,const 表示是常量值引用,非常快

这两项的差距就说明了 UNION 要优于 OR

7、优化分页查询

一般分页查询时,通过创建覆盖索引能够比较好地提高性能。一个常见又非常头疼的问题就是 limit 2000000,10 ,此时需要MySQL排序前2000010 记录,仅仅返回2000000 - 2000010 的记录,其他记录丢弃,因此使用limit进行分页查询的时候,越往后,耗费的时间越长,查询排序的代价就越大 。

1556361314783

1、优化思路一

在索引上完成排序分页操作,最后根据主键关联回原表查询所需要的其他列内容

1556416102800

2、优化思路二

该方案适用于==主键自增==的表,可以把Limit 查询转换成某个位置的查询 。

1556363928151

一般来说思路二要比思路一要简单而且效率要好,但是思路二有许多的限制:

  • 主键自增
  • 不能出现断层

8、优化单表查询

1、建表SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE IF NOT EXISTS `article` (
`id` INT(10) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
`author_id` INT(10) UNSIGNED NOT NULL,
`category_id` INT(10) UNSIGNED NOT NULL,
`views` INT(10) UNSIGNED NOT NULL,
`comments` INT(10) UNSIGNED NOT NULL,
`title` VARBINARY(255) NOT NULL,
`content` TEXT NOT NULL
);

INSERT INTO `article`(`author_id`, `category_id`, `views`, `comments`, `title`, `content`) VALUES
(1, 1, 1, 1, '1', '1'),
(2, 2, 2, 2, '2', '2'),
(1, 1, 3, 3, '3', '3');

SELECT * FROM article;
2、案例

查询 category_id 为1 且 comments 大于 1 的情况下,views 最多的 article_id。

1
EXPLAIN SELECT id,author_id FROM article WHERE category_id = 1 AND comments > 1 ORDER BY views DESC LIMIT 1;

结论:很显然,type 是 ALL,即最坏的情况。Extra 里还出现了 Using filesort,也是最坏的情况。优化是必须的。

开始优化:

  1. 新建索引+删除索引

    1
    2
    3
    -- ALTER TABLE `article` ADD INDEX idx_article_ccv ( `category_id` , `comments`, `views` );
    create index idx_article_ccv on article(category_id,comments,views);
    DROP INDEX idx_article_ccv ON article
  2. 第2次EXPLAIN

    1
    EXPLAIN SELECT id,author_id FROM `article` WHERE category_id = 1 AND comments >1 ORDER BY views DESC LIMIT 1;

    结论:type 变成了 range,这是可以忍受的。但是 extra 里使用 Using filesort 仍是无法接受的。

    但是我们已经建立了索引,为啥没用呢?

    • 这是因为按照 BTree 索引的工作原理,先排序 category_id,如果遇到相同的 category_id 则再排序 comments,如果遇到相同的 comments 则再排序 views。
    • 当 comments 字段在联合索引里处于中间位置时,因comments > 1 条件是一个范围值(所谓 range),MySQL 无法利用索引再对后面的 views 部分进行检索,即 range 类型查询字段后面的索引无效。
  3. 删除第一次建立的索引

    1
    DROP INDEX idx_article_ccv ON article;
  4. 第2次新建索引

    1
    2
    -- ALTER TABLE `article` ADD INDEX idx_article_cv ( `category_id` , `views` ) ;
    create index idx_article_cv on article(category_id,views);
  5. 第3次EXPLAIN

    1
    EXPLAIN SELECT id,author_id FROM article WHERE category_id = 1 AND comments > 1 ORDER BY views DESC LIMIT 1;

    结论:可以看到,type 变为了 ref,Extra 中的 Using filesort 也消失了,结果非常理想。

9、优化关联查询

1、建表SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
CREATE TABLE IF NOT EXISTS `class` (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`card` INT(10) UNSIGNED NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE IF NOT EXISTS `book` (
`bookid` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`card` INT(10) UNSIGNED NOT NULL,
PRIMARY KEY (`bookid`)
);

INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO class(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
INSERT INTO book(card) VALUES(FLOOR(1 + (RAND() * 20)));
2、案例

下面开始explain分析

1
EXPLAIN SELECT * FROM class LEFT JOIN book ON class.card = book.card;

结论:type 有All

添加索引优化:

1
ALTER TABLE `book` ADD INDEX Y ( `card`);

第2次explain:

1
EXPLAIN SELECT * FROM class LEFT JOIN book ON class.card = book.card;

结论:可以看到第二行的 type 变为了 ref,rows 也变成了优化比较明显。

这是由左连接特性决定的。LEFT JOIN 条件用于确定如何从右表搜索行,左边一定都有,所以右边是我们的关键点,一定需要建立索引。

3、建议
  1. 保证**==被驱动表==的join字段已经被索引**

    • 被驱动表 join 后的表为被驱动表 (需要被查询)
  2. left join 时,选择小表作为驱动表,大表作为被驱动表。(原则:小表驱动大表)

    • left join 时一定是左边是驱动表,右边是被驱动表
  3. inner join 时,mysql会自己帮你把小结果集的表选为驱动表

  4. 子查询尽量不要放在被驱动表,有可能使用不到索引。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    select 
    a.name,bc.name
    from
    t_emp a
    left join
    (select
    b.id,c.name
    from
    t_dept b
    inner join
    t_emp c
    on b.ceo = c.id)
    on bc.id = a.deptid;

    上段查询中用到了子查询,必然 bc 表没有索引。肯定会进行全表扫描

    上段查询 可以直接使用 两个 left join 优化:

    1
    2
    3
    select a.name , c.name from t_emp a
    left outer join t_dept b on a.deptid = b.id
    left outer join t_emp c on b.ceo=c.id

    所有条件都可以使用到索引

    若必须用到子查询,可将子查询设置为驱动表。

    • 因为驱动表的type 肯定是 all,而子查询返回的结果表没有索引,必定也是all

8、使用SQL提示

SQL提示,是优化数据库的一个重要手段,简单来说,就是在SQL语句中加入一些人为的提示来达到优化操作的目的

1、USE INDEX

在查询语句中表名的后面,添加 use index 来提供希望MySQL去==参考==的索引列表(数据库不一定使用),就可以让MySQL不再考虑其他可用的索引。

1
create index idx_seller_name on tb_seller(name);

1556370971576

2、IGNORE INDEX

如果用户只是单纯的想让MySQL忽略一个或者多个索引,则可以使用 ignore index 作为 hint 。

1
explain select * from tb_seller ignore index(idx_seller_name) where name = '小米科技';

1556371004594

3、FORCE INDEX

为强制MySQL使用一个特定的索引,可在查询中使用 force index 作为hint 。

1
create index idx_seller_address on tb_seller(address);

1556371355788


10、应用优化

前面章节,我们介绍了很多数据库的优化措施。但是在实际生产环境中,由于数据库本身的性能局限,就必须要对前台的应用进行一些优化,来降低数据库的访问压力。

1、使用连接池

对于访问数据库来说,建立连接的代价是比较昂贵的,因为我们频繁的创建关闭连接,是比较耗费资源的,我们有必要建立 数据库连接池,以提高访问的性能。

2、减少对MySQL的访问

1、避免对数据进行重复检索

在编写应用代码时,需要能够理清对数据库的访问逻辑。能够一次连接就获取到结果的,就不用两次连接,这样可以大大减少对数据库无用的重复请求。

比如 ,需要获取书籍的id 和name字段 , 则查询如下:

1
select id , name from tb_book;

之后,在业务逻辑中有需要获取到书籍状态信息, 则查询如下:

1
select id , status from tb_book;

这样,就需要向数据库提交两次请求,数据库就要做两次查询操作。其实完全可以用一条SQL语句得到想要的结果。

1
select id, name , status from tb_book;
2、增加cache层

在应用中,我们可以在应用中增加 缓存 层来达到减轻数据库负担的目的。缓存层有很多种,也有很多实现方式,只要能达到降低数据库的负担又能满足应用需求就可以。

因此可以部分数据从数据库中抽取出来放到应用端以文本方式存储(采用配置文件的方式), 或者使用框架(Mybatis, Hibernate)提供的一级缓存/二级缓存,或者使用redis数据库来缓存数据

3、负载均衡

负载均衡是应用中使用非常普遍的一种优化方法,它的机制就是利用某种均衡算法,将固定的负载量分布到不同的服务器上, 以此来降低单台服务器的负载,达到优化的效果

1、利用MySQL复制分流查询

通过MySQL的主从复制,实现读写分离,使增删改操作走主节点,查询操作走从节点,从而可以降低单台服务器的读写压力

1

2、采用分布式数据库架构

分布式数据库架构适合大数据量、负载高的情况,它有良好的拓展性和高可用性。通过在多台服务器之间分布数据,可以实现在多台服务器之间的负载均衡,提高访问效率


11、Mysql中查询缓存优化

1、概述

开启Mysql的查询缓存,当执行完全相同的SQL语句的时候,服务器就会直接从缓存中读取结果,当数据被修改,之前的缓存会失效,修改比较频繁的表不适合做查询缓存。MySQL 5.6(2013)以来,查询缓存已被禁用。

2、操作流程

20180919131632347

  1. 客户端发送一条查询给服务器;
  2. 服务器先会检查查询缓存,如果命中了缓存,则立即返回存储在缓存中的结果。否则进入下一阶段;
  3. 服务器端进行SQL解析、预处理,再由优化器生成对应的执行计划;
  4. MySQL根据优化器生成的执行计划,调用存储引擎的API来执行查询;
  5. 将结果返回给客户端。

3、查询缓存配置

  1. 查看当前的MySQL数据库是否支持查询缓存:

    1
    SHOW VARIABLES LIKE 'have_query_cache';	

    ![1555249929012](D:\编程\黑马\mysql高级/资料-MySQL高级教程\MySQL 高级 - day-03\文档\assets\1555249929012.png)

  2. 查看当前MySQL是否开启了查询缓存:

    1
    SHOW VARIABLES LIKE 'query_cache_type';

    ![1555250015377](D:\编程\黑马\mysql高级/资料-MySQL高级教程\MySQL 高级 - day-03\文档\assets\1555250015377.png)

  3. 查看查询缓存的占用大小 :(单位:字节)默认:1M。如果想要增加的话,建议增加的值为1024的倍数

    1
    SHOW VARIABLES LIKE 'query_cache_size';

    ![1555250142451](D:\编程\黑马\mysql高级/资料-MySQL高级教程\MySQL 高级 - day-03\文档\assets\1555250142451.png)

  4. 查看查询缓存的状态变量:

    1
    SHOW STATUS LIKE 'Qcache%';

    ![1555250443958](D:\编程\黑马\mysql高级/资料-MySQL高级教程\MySQL 高级 - day-03\文档\assets\1555250443958.png)

    各个变量的含义如下:

    参数 含义
    Qcache_free_blocks 查询缓存中的可用内存块数
    Qcache_free_memory 查询缓存的可用内存量
    ==Qcache_hits== 查询缓存命中数
    ==Qcache_inserts== 添加到查询缓存的查询数
    Qcache_lowmen_prunes 由于内存不足而从查询缓存中删除的查询数
    ==Qcache_not_cached== 非缓存查询的数量(由于 query_cache_type 设置而无法缓存或未缓存)
    Qcache_queries_in_cache 查询缓存中注册的查询数
    Qcache_total_blocks 查询缓存中的块总数

4、开启查询缓存

MySQL的查询缓存默认是关闭的,需要手动配置参数 query_cache_type , 来开启查询缓存

query_cache_type 该参数的可取值有三个:

含义
OFF 或 0 查询缓存功能关闭
ON 或 1 查询缓存功能打开,SELECT的结果符合缓存条件即会缓存,否则,不予缓存,显式指定 SQL_NO_CACHE,不予缓存
DEMAND 或 2 查询缓存功能按需进行,显式指定 SQL_CACHE 的SELECT语句才会缓存;其它均不予缓存

在 /usr/my.cnf 配置中,增加以下配置:

1555251383805

配置完毕之后,重启服务既可生效 ;

然后就可以在命令行执行SQL语句进行验证 ,执行一条比较耗时的SQL语句,然后再多执行几次,查看后面几次的执行时间;获取通过查看查询缓存的缓存命中数,来判定是否走查询缓存。

5、查询缓存SELECT选项

可以在SELECT语句中指定两个与查询缓存相关的选项:

  • SQL_CACHE:如果查询结果是可缓存的,并且 query_cache_type 系统变量的值为ON或 DEMAND ,则缓存查询结果 。
  • SQL_NO_CACHE:服务器不使用查询缓存。它既不检查查询缓存,也不检查结果是否已缓存,也不缓存查询结果。

例子:

1
2
SELECT SQL_CACHE id, name FROM customer;
SELECT SQL_NO_CACHE id, name FROM customer;

6、查询缓存失效的情况

1、SQL 语句不一致的情况, 要想命中查询缓存,查询的SQL语句必须一致
1
2
SQL1 : select count(*) from tb_item;
SQL2 : Select count(*) from tb_item;
2、当查询语句中有一些不确定的时,则不会缓存

如 : now() , current_date() , curdate() , curtime() , rand() , uuid() , user() , database() 。

1
2
3
SQL1 : select * from tb_item where updatetime < now() limit 1;
SQL2 : select user();
SQL3 : select database();
3、不使用任何表查询语句
1
select 'A';
4、查询 mysql, information_schema或 performance_schema 数据库中的表时,不会走查询缓存
1
select * from information_schema.engines;
5、在存储的函数,触发器或事件的主体内执行的查询
6、如果表更改,则使用该表的所有高速缓存查询都将变为无效并从高速缓存中删除

这包括使用MERGE映射到已更改表的表的查询。一个表可以被许多类型的语句,如被改变 INSERT, UPDATE, DELETE, TRUNCATE TABLE, ALTER TABLE, DROP TABLE,或 DROP DATABASE 。当然,与此同时也会将更改之后的表重新加入查询缓存当中


12、Mysql内存管理及优化

1、内存优化原则

  1. 将尽量多的内存分配给MySQL做缓存,但要给操作系统和其他程序预留足够内存。
  2. MyISAM 存储引擎的数据文件读取依赖于操作系统自身的IO缓存,因此,如果有MyISAM表,就要预留更多的内存给操作系统做IO缓存
  3. 排序区、连接区等缓存是分配给每个数据库会话(session)专用的,其默认值的设置要根据最大连接数合理分配,如果设置太大,不但浪费资源,而且在并发连接较高时会导致物理内存耗尽。

2、MyISAM 内存优化

myisam存储引擎使用 key_buffer 缓存索引块,加速myisam索引的读写速度。对于myisam表的数据块,mysql没有特别的缓存机制,完全依赖于操作系统的IO缓存

1、key_buffer_size

key_buffer_size决定MyISAM索引块缓存区的大小,直接影响到MyISAM表的存取效率。可以在MySQL参数文件中设置key_buffer_size的值,对于一般MyISAM数据库,建议至少将1/4可用内存分配给key_buffer_size。

在/usr/my.cnf 中做如下配置:(默认值为8388608字节 = 8M)

1
key_buffer_size=512M

image-20210902012301229

2、read_buffer_size

如果需要经常顺序扫描myisam表,可以通过增大read_buffer_size的值来改善性能。但需要注意的是read_buffer_size是每个session独占的,如果默认值设置太大,就会造成内存浪费

3、read_rnd_buffer_size

对于需要做排序的myisam表的查询,如带有order by子句的sql,适当增加 read_rnd_buffer_size 的值,可以改善此类的sql性能。但需要注意的是 read_rnd_buffer_size 是每个session独占的,如果默认值设置太大,就会造成内存浪费。

3、InnoDB 内存优化

innodb用一块内存区做IO缓存池,该缓存池不仅用来缓存innodb的索引块,而且也用来缓存innodb的数据块。

1、innodb_buffer_pool_size

该变量决定了 innodb 存储引擎表数据和索引数据的最大缓存区大小

在保证操作系统及其他程序有足够内存可用的情况下,innodb_buffer_pool_size 的值越大,缓存命中率越高,访问InnoDB表需要的磁盘I/O 就越少,性能也就越高。

1
innodb_buffer_pool_size=512M

innodb_buffer_pool_size 的默认大小为134217728字节 = 128M

image-20210902012643822

2、innodb_log_buffer_size

决定了innodb重做日志缓存的大小,对于可能产生大量更新记录的大事务,增加innodb_log_buffer_size的大小,可以避免innodb在事务提交前就执行不必要的日志写入磁盘操作

1
innodb_log_buffer_size=10M

13、Mysql并发参数调整

从实现上来说,MySQL Server 是多线程结构,包括后台线程和客户服务线程。多线程可以有效利用服务器资源,提高数据库的并发性能

在Mysql中,控制并发连接和线程的主要参数包括:

  • max_connections
  • back_log
  • thread_cache_size
  • table_open_cahce

1、max_connections

采用max_connections 控制允许连接到MySQL数据库的最大数量,默认值是 151。

如果状态变量 connection_errors_max_connections 不为零,并且一直增长,则说明不断有连接请求因数据库连接数已达到允许最大值而失败,这是可以考虑增大max_connections 的值。

Mysql 最大可支持的连接数,取决于很多因素,包括给定==操作系统平台的线程库的质量==、==内存大小==、==每个连接的负荷==、==CPU的处理速度==,==期望的响应时间==等。在Linux 平台下,性能好的服务器,支持 500-1000 个连接不是难事,需要根据服务器性能进行评估设定。

image-20210902013047619

2、back_log

back_log 参数控制MySQL监听TCP端口时设置的积压请求栈大小

如果MySql的连接数达到max_connections时,新来的请求将会被存在堆栈中,以等待某一连接释放资源,该堆栈的数量即back_log,如果等待连接的数量超过back_log,将不被授予连接资源,将会报错。

5.6.6 版本之前默认值为 50 , 之后的版本默认为 50 + (max_connections / 5), 但最大不超过900。

如果需要数据库在较短的时间内处理大量连接请求, 可以考虑适当增大back_log 的值。

image-20210902013106674

3、table_open_cache

该参数用来控制==所有SQL语句==执行线程可打开表缓存的数量, 而在执行SQL语句时,每一个SQL执行线程至少要打开 1 个表缓存。该参数的值应该根据设置的最大连接数 max_connections 以及每个连接执行关联查询中涉及的表的最大数量来设定max_connections x N默认值为:2000

image-20210902013213494

4、thread_cache_size

为了加快连接数据库的速度,MySQL 会缓存一定数量的客户服务线程以备重用,通过参数 thread_cache_size 可控制 MySQL 缓存客户服务线程的数量。

默认大小为:9

image-20210902013312194

5、innodb_lock_wait_timeout

该参数是用来设置InnoDB 事务等待行锁的时间,默认值是50ms ,可以根据需要进行动态设置

  • 对于需要快速反馈的业务系统来说,可以将行锁的等待时间调小,以避免事务长时间挂起
  • 对于后台运行的批量处理程序来说, 可以将行锁的等待时间调大, 以避免发生大的回滚操作

image-20210902013414184


14、Mysql锁问题

1、锁概述

锁是计算机协调多个进程或线程并发访问某一资源的机制(避免争抢)。

在数据库中,除传统的计算资源(如 CPU、RAM、I/O 等)的争用以外,数据也是一种供许多用户共享的资源

如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要,也更加复杂。

2、锁分类

从对数据操作的粒度分:

  1. 表锁:操作时,会锁定整个表。
  2. 行锁:操作时,会锁定当前操作行。

从对数据操作的类型分:

  1. 读锁(共享锁)(S Lock):针对同一份数据,多个读操作可以同时进行而不会互相影响。
  2. 写锁(排它锁)(X Lock):当前操作没有完成之前,它会阻断其他写锁和读锁。

3、行锁:记录锁(Record Locks)

mysql的行锁是通过索引加载的,即行锁是加在索引响应的行上的,要是对应的SQL语句没有走索引,则会全表扫描.

  1. 记录锁,仅仅锁住索引记录的一行,在单条索引记录上加锁
  2. record lock锁住的永远是索引,而非记录本身,即使该表上没有任何索引,那么innodb会在后台创建一个隐藏的聚集主键索引,那么锁住的就是这个隐藏的聚集主键索引。

所以说当一条sql没有走任何索引时,那么将会在每一条聚合索引后面加X锁,这个类似于表锁,但原理上和表锁应该是完全不同的。

4、行锁:间隙锁(Gap Locks)

  1. 区间锁,仅仅锁住一个索引区间(开区间,不包括双端端点)
  2. 在索引记录之间的间隙中加锁,或者是在某一条索引记录之前或者之后加锁,==并不包括该索引记录本==身。比如在 1、2、3中,间隙锁的可能值有 (∞, 1),(1, 2),(2, ∞),
  3. 间隙锁可用于防止幻读,保证索引间的不会被插入数据

5、行锁:临键锁(Next-Key Locks)

  1. record lock + gap lock,左开右闭区间。

  2. 默认情况下,innodb使用next-key locks来锁定记录。

    1
    selectfor update
  3. 但当查询的索引含有唯一属性的时候,Next-Key Lock 会进行优化,将其降级为Record Lock,即仅锁住索引本身,不是范围。

  4. Next-Key Lock在不同的场景中会退化:

场景 退化锁类型
使用unique index精确匹配(=),且记录存在 Record Locks
使用unique index精确匹配(=),且记录不存在 Gap Locks
使用unique index范围匹配(<和> Record Locks + Gap Locks,锁上界不锁下界(左开右闭区间)

6、表锁:意向锁

表锁,其实也可以叫意向锁,表明“某个事务正在某些行持有了锁、或该事务准备去持有锁”。

  • 注意:表锁可以是意向锁,但是意向锁不一定是表锁

意向锁产生的主要目的是为了处理行锁和表锁之间的冲突

  • 事务在请求S锁和X锁前,需要先获得对应的IS、IX锁,
  • 在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。

例子:事务A修改user表的记录r,会给记录r上一把行级的排他锁(X),同时会给user表上一把意向排他锁(IX),这时事务B要给user表上一个表级的排他锁就会被阻塞。意向锁通过这种方式实现了行锁和表锁共存且满足事务隔离性的要求。

  1. 意向共享锁(IS锁):事务在请求S锁前,要先获得IS锁
  2. 意向排他锁(IX锁):事务在请求X锁前,要先获得IX锁

q1:为什么意向锁是表级锁呢?

当我们需要加一个排他锁时,需要根据意向锁去判断表中有没有数据行被锁定(行锁);

  • 如果意向锁是行锁,则需要遍历每一行数据去确认;
  • 如果意向锁是表锁,则只需要判断一次即可知道有没数据行被锁定,提升性能。

q2:意向锁怎么支持表锁和行锁并存?

  • 首先明确并存的概念是指数据库同时支持表、行锁,而不是任何情况都支持一个表中同时有一个事务A持有行锁、又有一个事务B持有表锁,因为表一旦被上了一个表级的写锁,肯定不能再上一个行级的锁

  • 如果事务A对某一行上锁,其他事务就不可能修改这一行。这与“事务B锁住整个表就能修改表中的任意一行”形成了冲突。所以,没有意向锁的时候,让行锁与表锁共存,就会带来很多问题。于是有了意向锁的出现,如q1的答案中,数据库不需要在检查每一行数据是否有锁,而是直接判断一次意向锁是否存在即可,能提升很多性能。

    1. 共享锁和排他锁,系统在特定的条件下会自动添加共享锁或者排他锁,也可以手动添加共享锁或者排他锁。
    2. 意向共享锁和意向排他锁都是系统自动添加和自动释放的,整个过程无需人工干预
    3. 共享锁和排他锁都是锁的行记录,意向共享锁和意向排他锁锁定的是表
    4. 由于InnoDB存储引擎支持的是行级别的锁,因此意向锁不会阻塞除全表扫描以外的任何请求。
  • 意向共享锁与排他锁冲突,也就是说如果A表中一行被加了排它锁,那么当有select * 这样的全表扫描语句的时候将会加锁失败,因为全表扫描需要对表加意向共享锁,但是表上有排他行锁,于是加锁失败;

  • 意向排他锁与排他锁和共享锁都冲突,同理也就是说如果A表中一行被加了排它锁或者共享锁,那么当有需要加表级的意向排它锁的时候,加锁失败;

3、Mysql 锁

相对其他数据库而言,MySQL的锁机制比较简单,其最显著的特点是不同的存储引擎支持不同的锁机制

下表中罗列出了各存储引擎对锁的支持情况:

存储引擎 表级锁 行级锁 页面锁
MyISAM 支持 不支持 不支持
==InnoDB== 支持 支持 不支持
MEMORY 支持 不支持 不支持
BDB 支持 不支持 支持

MySQL这3种锁的特性可大致归纳如下:

锁类型 特点
表级锁 偏向MyISAM 存储引擎,开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
行级锁 偏向InnoDB 存储引擎,开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
页面锁 开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。

从上述特点可见,很难笼统地说哪种锁更好,只能就具体应用的特点来说哪种锁更合适!

  • 仅从锁的角度来说:表级锁更适合于以查询为主,只有少量按索引条件更新数据的应用,如Web 应用
  • 行级锁则更适合于有大量按索引条件并发更新少量不同数据,同时又有并查询的应用,如一些在线事务处理(OLTP)系统。

4、MyISAM 表锁

MyISAM 存储引擎只支持表锁,这也是MySQL开始几个版本中唯一支持的锁类型

1、如何加表锁
  • MyISAM 在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁
  • 在执行更新操作(UPDATEDELETEINSERT 等)前,会自动给涉及的表加写锁
  • 这个过程并不需要用户干预,因此用户一般不需要直接用 LOCK TABLE 命令给 MyISAM 表显式加锁

显示加表锁语法:

1
2
3
4
5
6
7
8
-- 加读锁
lock table table_name read;

-- 加写锁
lock table table_name write;

-- 解锁
unlock tables
2、读锁案例

准备环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
create database demo_03 default charset=utf8mb4;

use demo_03;

CREATE TABLE `tb_book` (
`id` INT(11) auto_increment,
`name` VARCHAR(50) DEFAULT NULL,
`publish_time` DATE DEFAULT NULL,
`status` CHAR(1) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=myisam DEFAULT CHARSET=utf8 ;

INSERT INTO tb_book (id, name, publish_time, status) VALUES(NULL,'java编程思想','2088-08-01','1');
INSERT INTO tb_book (id, name, publish_time, status) VALUES(NULL,'solr编程思想','2088-08-08','0');



CREATE TABLE `tb_user` (
`id` INT(11) auto_increment,
`name` VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=myisam DEFAULT CHARSET=utf8 ;

INSERT INTO tb_user (id, name) VALUES(NULL,'令狐冲');
INSERT INTO tb_user (id, name) VALUES(NULL,'田伯光');

客户端一:

1)获得tb_book 表的读锁

1
lock table tb_book read;

2) 执行查询操作

1
select * from tb_book;

1553906896564

可以正常执行 , 查询出数据。

客户端二:

3) 执行查询操作

1
select * from tb_book;

1553907044500

客户端一:

4)查询未锁定的表

1
select name from tb_seller;

1553908913515

客户端二:

5)查询未锁定的表

1
select name from tb_seller;

1553908973840

可以正常查询出未锁定的表;

客户端一:

6) 执行插入操作

1
insert into tb_book values(null,'Mysql高级','2088-01-01','1');

1553907198462

执行插入, 直接报错 , 由于当前tb_book 获得的是 读锁, 不能执行更新操作。

客户端二:

7) 执行插入操作

1
insert into tb_book values(null,'Mysql高级','2088-01-01','1');

1553907403957

当在客户端一中释放锁指令 unlock tables 后 , 客户端二中的 inesrt 语句 , 立即执行 ;

3、写锁案例

客户端一:

1)获得tb_book 表的写锁

1
lock table tb_book write;

2)执行查询操作

1
select * from tb_book;

1553907849829

查询操作执行成功;

3)执行更新操作

1
update tb_book set name = 'java编程思想(第二版)' where id = 1;

1553907875221

更新操作执行成功 ;

客户端二:

4)执行查询操作

1
select * from tb_book ;

1553908019755

当在客户端一中释放锁指令 unlock tables 后 , 客户端二中的 select 语句 , 立即执行 ;

1553908131373

4、结论

锁模式的相互兼容性如表中所示:

image-20210902030926626

由上表可见:

  1. 对MyISAM 表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;
  2. 对MyISAM 表的写操作,则会阻塞其他用户对同一表的读和写操作;

简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁,则既会阻塞读,又会阻塞写

此外,MyISAM 的读写锁调度是==写优先==,这也是MyISAM不适合做写为主的表的存储引擎的原因。因为写锁后,其他线程不能做任何操作,大量的更新会使查询很难得到锁,从而造成永远阻塞。

5、查看锁的争用情况
1
show open tables

image-20210902031019830

  • In_user表当前被查询使用的次数。如果该数为零,则表是打开的,但是当前没有被使用。
  • Name_locked表名称是否被锁定名称锁定用于==取消表==或==对表进行重命名==等操作
1
show status like 'Table_locks%';
  • Table_locks_immediate:指的是能够立即获得表级锁的次数,每立即获取锁,值加1
  • Table_locks_waited:指的是不能立即获取表级锁而需要等待的次数,每等待一次,该值加1,此值高说明存在着较为严重的表级锁争用情况。

5、InnoDB 行锁

1、行锁介绍

行锁特点 :

  • 偏向InnoDB 存储引擎,开销大,加锁慢
  • 会出现死锁
  • 锁定粒度最小,发生锁冲突的概率最低,并发度也最高

InnoDB 与 MyISAM 的最大不同有两点:

  1. 一是==支持事务==;
  2. 二是 ==采用了行级锁==。
2、背景知识
1、事务及其ACID属性

事务是由一组SQL语句组成的逻辑处理单元。

事务具有以下4个特性,简称为事务==ACID属性==。

ACID属性 含义
原子性(Atomicity) 事务是一个原子操作单元,其对数据的修改,要么全部成功,要么全部失败。
一致性(Consistent) 在事务开始和完成时,数据都必须保持一致状态。
隔离性(Isolation) 数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的 “独立” 环境下运行。
持久性(Durable) 事务完成之后,对于数据的修改是永久的。
2、并发事务处理带来的问题
问题 含义
丢失更新(Lost Update) 当两个或多个事务选择同一行,最初的事务修改的值,会被后面的事务修改的值覆盖。
脏读(Dirty Reads) 当一个事务正在访问数据,并且对数据进行了修改,而这种修改还没有提交到数据库中,这时,另外一个事务也访问这个数据,然后使用了这个数据。
不可重复读(Non-Repeatable Reads) 一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现和以前读出的数据不一致。
幻读(Phantom Reads) 一个事务按照相同的查询条件重新读取以前查询过的数据,却发现其他事务插入了满足其查询条件的新数据。
3、事务隔离级别

为了解决上述提到的事务并发问题,数据库提供一定的事务隔离机制来解决这个问题。数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使用事务在一定程度上“串行化” 进行,这显然与“并发” 是矛盾的。

数据库的隔离级别有4个,由低到高依次为:

  • Read uncommitted
  • Read committed(Oracle默认)
  • Repeatable read(Mysql默认)
  • Serializable

这四个级别可以逐个解决脏写、脏读、不可重复读、幻读这几类问题。

隔离级别 丢失更新 脏读 不可重复读 幻读
Read uncommitted ×
Read committed × ×
Repeatable read(默认) × × ×
Serializable × × × ×

备注 : √ 代表可能出现 , × 代表不会出现 。

Mysql 的数据库的默认隔离级别为 Repeatable read , 查看方式:

1
show variables like 'tx_isolation';

1554331600009

4、解决方案解析

解决脏读——使用 读已提交:

首先我们要理解什么是脏读,既然脏读是一个事物读到了另一个事务未提交的数据,那么我们就让它提交后再让别的事物读就好了,读已提交就是改变了释放锁的时机,让事务完成提交后再去释放锁。这样就解决了脏读问题。

解决不可重复读——使用 可重复读:

不可重复读是因为读取过程中有其他事务修改数据,导致读取数据不一致。那我们就要保证一个事务读取数据的时候就让他老老实实读那个数据。可重复读是用一个MVCC(多版本并发控制)机制去解决的

MVCC(多版本并发控制)

MVCC其实就是行级锁的一个升级版。我们都知道数据库中有表锁和行锁,在表锁中读写操作是阻塞的,而MVCC的读写一般是不会阻塞的,这样避免了很多加锁过程。

MVCC具体实现:通过在每行记录后面保存两个隐藏的字段,一个保存的是此行的创建时间,一个保存的是指向旧版本的指针。它们存储的也并不是真的时间,而是系统版本号。就跟我们使用软件都有1.0,2.0这些版本,每个版本有它们自己的特点和数据。MVCC就是在每次开始事务时,都会对应自动递增并保存一个版本号,通过这个版本号去生成对应的一个时间点的数据快照,利用这个快照就可以保证数据读取的一致性。

MVCC把SQL分为两类:一种是快照读,就是普通的select操作,读的就是历史版本的数据。另一种是当前读,比如select … for update,insert,update,delete 读的都是最新的数据,不可重复读就是利用快照保存数据,然后就解决啦!

通俗的说就是:MVCC就是给每次事务操作的数据行都加个字段,代表这次事务的版本,那么如果我现在读取这行数据时,就会通过这个版本生成一个数据快照,那么我这个事务再读的时候,会直接从版本快照中获得数据,相当于帮我们缓存了一份数据,注意喔,我这两次读取都是同一个事务喔!

解决幻读——使用 串行化:

上文说到解决不可重复读用MVCC就可以根据版本保证读取的数据一致,那幻读不是也可以用这个去解决吗?

那么这里又要重申幻读和不可重复读的区别,不可重复读是针对某行数据,幻读是特指查询到记录条数,也就是多条数据的查询。

所以我们使用MVCC + Next-key Lock锁去解决幻读问题。

科普一下Innodb的三种行锁的算法:

  • 行锁:就是对单条记录上锁。
  • 间隙锁:锁定一个范围,但是不会包括记录自己,就是如果要查询一个id=10的数据时,就会把它范围外的加上锁防止插入数据操作,这个就是间隙锁。
  • Next-key Lock:行锁+间隙锁的合体算法,使用它时不仅会把id=10 的范围加上行锁,也会把它间隙加上锁,对于行的查询,使用此法便ok。

具体实现:

当事务执行的是select…for update 时,Next-key Lock对范围加锁,这样事务A执行这个查询当前读语句时,事务B是不能去修改范围内的数据的。

遇到的问题:

我们使用串行化解决幻读会有什么问题产生呢?
我们知道解决幻读使用了间隙锁,那么我们在并发情况下很容易造成死锁!

举个栗子:

事务A、事务B同时执行select * from table where id = 10 for update ,前提是我们没有id=10这条数据,事务A执行时因加上了间隙锁,同时事务B也执行这条语句。这时,事务B如果去添加数据就会因为事务A的间隙锁造成阻塞,事务A再执行添加数据也会因为事务B间隙锁造成阻塞,这样就形成了一个死锁。

所以串行化的并发性不好,那我们实际项目中要合理的选择取舍。

3、InnoDB 的行锁模式

InnoDB 实现了以下两种类型的行锁:

  • 共享锁(S):又称为==读锁==,简称==S锁==,**共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是==只能读不能修改==**。
  • 排他锁(X):又称为==写锁==,简称==X锁==,排他锁就是不能与其他锁并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。

对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加排他锁(X);

对于普通SELECT语句,InnoDB不会加任何锁;

可以通过以下语句显示给记录集加共享锁或排他锁 。

1
2
3
4
5
-- 共享锁(S)
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE

-- 排他锁(X)
SELECT * FROM table_name WHERE ... FOR UPDATE
4、案例准备工作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
create table test_innodb_lock(
id int(11),
name varchar(16),
sex varchar(1)
)engine = innodb default charset=utf8;

insert into test_innodb_lock values(1,'100','1');
insert into test_innodb_lock values(3,'3','1');
insert into test_innodb_lock values(4,'400','0');
insert into test_innodb_lock values(5,'500','1');
insert into test_innodb_lock values(6,'600','0');
insert into test_innodb_lock values(7,'700','0');
insert into test_innodb_lock values(8,'800','1');
insert into test_innodb_lock values(9,'900','1');
insert into test_innodb_lock values(1,'200','0');

-- 创建两个单列索引
create index idx_test_innodb_lock_id on test_innodb_lock(id);
create index idx_test_innodb_lock_name on test_innodb_lock(name);
5、行锁基本演示
Session-1 Session-2
image-20210902025707245 关闭自动提交功能 image-20210902025719304 关闭自动提交功能
image-20210902025728670 可以正常的查询出全部的数据 image-20210902025737987可以正常的查询出全部的数据
image-20210902025748385查询id 为3的数据 ; image-20210902025758851查询id为3的数据 ;
image-20210902025807893更新id为3的数据,但是不提交; image-20210902025816158更新id为3 的数据, 出于等待状态
image-20210902025826632通过commit, 提交事务 image-20210902025833220解除阻塞,更新正常进行
以上, 操作的都是同一行的数据,接下来,演示不同行的数据 :
image-20210902025841993 更新id为3数据,正常的获取到行锁 , 执行更新 ; image-20210902025847203由于与Session-1 操作不是同一行,获取当前行锁,执行更新;
6、无索引行锁升级为表锁

条件不具备索引性质(索引失效或不是索引),则会导致行锁变为表锁

如果不通过索引条件检索数据,那么InnoDB将对表中的所有记录加锁,实际效果跟表锁一样

查看当前表的索引:

1
show  index  from test_innodb_lock ;

1554385956215

Session-1 Session-2
关闭事务的自动提交image-20210902025946304 关闭事务的自动提交image-20210902025952429
执行更新语句 :image-20210902025958345 执行更新语句, 但处于阻塞状态:image-20210902030003757
提交事务:image-20210902030008546 解除阻塞,执行更新成功 :image-20210902030013887
执行提交操作 :image-20210902030020857

由于执行更新时 , name字段本来为varchar类型, 我们是作为数组类型使用,存在类型转换,索引失效,最终行锁变为表锁

7、间隙锁危害

当我们用范围条件,而不是使用相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据进行加锁; 对于键值在条件范围内但并不存在的记录,叫做 “间隙(GAP)” , InnoDB也会对这个 “间隙” 加锁,这种锁机制就是所谓的 间隙锁(Next-Key锁)

示例 :

Session-1 Session-2
关闭事务自动提交 image-20210902030147061 关闭事务自动提交image-20210902030153667
根据id范围更新数据image-20210902030200257 此时id=2的数据不存在(间隙)
插入id为2的记录, 出于阻塞状态image-20210902030211686
提交事务 ;image-20210902030217111
解除阻塞 , 执行插入操作 :image-20210902030222274
提交事务 :
8、InnoDB 行锁争用情况
1
show status like 'innodb_row_lock%';

1556455943670

  • Innodb_row_lock_current_waits当前正在等待锁定的数量
  • Innodb_row_lock_time从系统启动到现在锁定总时间长度
  • Innodb_row_lock_time_avg:**==每次等待所花平均时长==**
  • Innodb_row_lock_time_max从系统启动到现在等待最长的一次所花的时间
  • Innodb_row_lock_waits:**==系统启动后到现在总共等待的次数==**

当等待的次数很高,而且每次等待的时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手制定优化计划。

9、总结

InnoDB存储引擎由于实现了行级锁定,虽然在锁定机制的实现方面带来了性能损耗可能比表锁会更高一些,但是在整体并发处理能力方面要远远由于MyISAM的表锁的。当系统并发量较高的时候,InnoDB的整体性能和MyISAM相比就会有比较明显的优势。

但是,InnoDB的行级锁同样也有其脆弱的一面,当我们使用不当的时候,可能会让InnoDB的整体性能表现不仅不能比MyISAM高,甚至可能会更差。

优化建议:

  • 尽可能让所有数据检索都能通过索引来完成,避免无索引行锁升级为表锁
  • 合理设计索引,尽量缩小锁的范围
  • 尽可能减少索引条件,及索引范围,避免间隙锁
  • 尽量控制事务大小,减少锁定资源量和时间长度
  • 尽可使用低级别事务隔离(但是需要业务层面满足需求)

15、Mysql 事务隔离的底层实现

1、MVCC(Multi-Version Concurrency Control)多版本并发控制

1、什么是MVCC?

MVCC(Multi-Version Concurrency Control)多版本并发控制,是数据库控制并发访问的一种手段。

在Mysql的InnoDB引擎中就是指在已提交读(READ COMMITTD)和可重复读(REPEATABLE READ)这两种隔离级别下的事务对于SELECT操作会访问版本链中的记录的过程。

这就使得别的事务可以修改这条记录,反正每次修改都会在版本链中记录。SELECT可以去版本链中拿记录,这就实现了读-写,写-读的并发执行,提升了系统的性能。

总结:

  • MVCC只在 读已提交(RC)可重复度(RR) 这两种事务隔离级别下才有效
  • 数据库引擎(InnoDB) 层面实现的,用来处理读写冲突的手段(不用加锁),提高访问性能
2、MVCC的底层原理——版本链和一致性视图
1、版本链
  • 版本链是一条链表,链接的是每条数据曾经的修改记录

那么这个版本链又是如何形成的呢,每条数据又是靠什么链接起来的呢?

其实是这样的,对于InnoDB存储引擎的表来说,它的聚簇索引记录包含两个隐藏字段:

  • trx_id:==存储修改此数据的事务id,只有这个事务操作了某些表的数据后当更改操作发生的时候(update,delete,insert),才会分配唯一的事务id,并且此事务id是递增的==
  • roll_pointer:指针,==指向上一次修改的记录==
  • row_id(非必须):当有==主键==或者有==不允许为null的unique键==时,不包含此字段

假如说当前数据库有一条这样的数据,假设是事务ID为100的事务插入的这条数据,那么此条数据的结构如下:

img

后来,事务200,事务300,分别来修改此数据:

时间T trx_id 200 trx_id 300
T1 开始事务 开始事务
T2 更改名字为A
T3 更改名字为B
T4 提交事务 更改名字为C
T6 提交事务

所以此时的版本链如下:

img

我们每更改一次数据,就会插入一条undo日志,并且记录的roll_pointer指针会指向上一条记录,如图所示:(注意:插入操作的undo日志没有roll_pointer这个属性,因为它没有老版本)

  1. 第一条数据是小杰,事务ID为100
  2. 事务ID为200的事务将名称从小杰改为了A
  3. 事务ID为200的事务将名称从A又改为了B
  4. 事务ID为300的事务将名称从B又改为了C

所以串成的链表就是 C -> B -> A -> 小杰 (从最新的数据到最老的数据)

2、一致性视图(ReadView)

需要判断版本链中的哪个版本是是当前事务可见的,因此有了一致性视图的概念。其中有四个属性比较重要:

  • m_ids:在生成ReadView时,当前活跃的读写事务的事务id列表
    • 当前活跃的读写事务就是begin了还未提交的事务
  • min_trx_id:m_ids的最小值
  • max_trx_id:m_ids的最大值+1
  • creator_trx_id:生成该事务的事务id,单纯开启事务是没有事务id的,默认为0,creator_trx_id是0

版本链中的当前版本是否可以被当前事务可见的要根据这四个属性按照以下几种情况来判断:

  • 当 trx_id = creator_trx_id 时:当前事务可以看见自己所修改的数据, 可见
  • 当 trx_id < min_trx_id 时:生成此数据的事务已经在生成readView前提交了, 可见
  • 当 trx_id >= max_trx_id 时:表明生成该数据的事务是在生成ReadView后才开启的, 不可见
  • 当 min_trx_id <= trx_id < max_trx_id 时
    • trx_id 在 m_ids 列表里面 :生成ReadView时,活跃事务还未提交,不可见
    • trx_id 不在 m_ids 列表里面 :事务在生成readView前已经提交了,可见

img

如果某个版本数据对当前事务不可见,那么则要顺着版本链继续向前寻找下个版本,继续这样判断,以此类推。

3、对于RR(可重复读)和RC(读已提交)在生成一致性视图时机的区别

对于事务的隔离级别:RR(可重复读)和RC(读已提交)在生成一致性视图的时机是不一样的:

  • 读提交(read committed RC) 是在每一次select的时候生成ReadView的

  • 可重复读(repeatable read RR)是在第一次select的时候生成ReadView的

示例:

多个事务如下执行,我们通过这个例子来分析当数据库隔离级别为RC和RR的情况下,当时读数据的一致性视图版本链,也就是MVCC,分别是怎么样的。

  • 假设数据库中有一条初始数据 姓名是小杰,id是1 (id,姓名,trx_id,roll_point),插入此数据的事务id是1
  • 尤其要指出的是,只有这个事务操作了某些表的数据后当更改操作发生的时候(update,delete,insert),才会分配唯一的事务id,并且此事务id是递增的,单纯开启事务是没有事务id的,默认为0,creator_trx_id是0。
  • 以下例子中的A,B,C的意思是将姓名更改为A,B,C,读也是读取当前时刻的姓名,默认全都开启事务,并且此事务都经历过某些操作产生了事务id
时间 事务100 事务200 事务300 事务400
T1 A
T2 B
T3 C
T4
T5 提交
T6 D
T7
T8 E
T9 提交
T10

1、读已提交(RC)与MVCC

  • 一个事务提交之后,它做的变更才会被其他事务看到
    • ==每次读==的时候,ReadView(一致性视图)都会重新生成
  1. 当T1时刻时,事务100修改名字为A
  2. 当T2时刻时,事务100修改名字为B
  3. 当T3时刻时,事务200修改名字为C
  4. 当T4时刻时,事务300开始读取名字

此时这条数据的版本链如下:(同颜色代表是同一事务内的操作)

img

此时T4时刻事务300要读了,究竟会读到什么数据

当前最近的一条数据是,C,事务200修改的,还记得我们前文说的一致性视图的几个属性和按照什么规则判断这个数据能不能被当前事务读。我们就分析这个例子。

此时 (生成一致性视图ReadView

  • m_ids 是{100,200}: 当前活跃的读写事务的事务id列表
  • min_trx_id 是 100: m_ids的最小值
  • max_trx_id 是 201: m_ids的最大值+1

当前数据的trx_id(事务id)是 200,符合min_trx_id<=trx_id<max_trx_id 此时需要判断:

  • trx_id 是否在m_ids活跃事务列表里面,
    • 一看,活跃事务列表里面是{100,200},只有两个事务活跃,而此时的trx_id是200,则trx_id在活跃事务列表里面,
  • 活跃事务列表代表还未提交的事务,所以该版本数据不可见,就要根据roll_point指针指向上一个版本,
  • 继续这样的判断,上一个版本事务id是100,数据是B,发现100也在活跃事务列表里面,所以不可见,
  • 继续找到上个版本,事务是100,数据是A,发现是同样的情况,
  • 继续找到上个版本,发现事务是1,数据是小杰,1小于100,trx_id<min_trx_id,代表生成这个数据的事务已经在生成ReadView前提交了,此数据可以被读到。所以读取的数据就是小杰

分析完第一个读,我们继续向下分析:

  1. 当T5时刻时,事务100提交
  2. 当T6时刻时,事务300将名字改为D
  3. 当T7时刻时,事务400读取当前数据

此时这条数据的版本链如下:

img

此时 (重新生成一致性视图ReadView

  • m_ids 是{200,300}: 当前活跃的读写事务的事务id列表

  • min_trx_id 是 200: m_ids的最小值

  • max_trx_id 是 301: m_ids的最大值+1

  • 当前数据事务id是300,数据为D,符合min_trx_id<=trx_id<max_trx_id

  • 此时需要判断数据是否在活跃事务列表里,300在这里面,所以就是还未提交的事务就是不可见,所以就去查看上个版本的数据,

  • 上个版本事务id是200,数据是C,也在活跃事务列表里面,也不可见,继续向上个版本找,

  • 上个版本事务id是100,数据是B,100小于min_trx_id,就代表,代表生成这个数据的事务已经在生成ReadView前提交了,此数据可见,所以读取出来的数据就是B

分析完第二个读,我们继续向下分析:

  1. 当T8时刻时,事务200将名字改为E
  2. 当T9时刻时,事务200提交
  3. 当T10时刻时,事务300读取当前数据

此时这条数据的版本链如下:

img

此时 (重新生成一致性视图ReadView

  • m_ids 是[300]: 当前活跃的读写事务的事务id列表
  • min_trx_id 是 300: m_ids的最小值
  • max_trx_id 是 301: m_ids的最大值+1

当前事务id是200,200<min_trx_id ,代表生成这个数据的事务已经在生成ReadView前提交了,此数据可见,所以读出的数据就是E.

总结:当隔离级别是读已提交RC的情况下,每次读都会重新生成 一致性视图(ReadView)

  • T4时刻 事务300读取到的数据是小杰
  • T7时刻 事务400读取到的数据是B
  • T10时刻 事务300读取到的数据是E

2、可重复读(RR)与MVCC

  • 一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的
    • 所以对于事务300来讲,它分别在T4和T10的时候,读取数据,但是它的一致性视图,用的永远都是第一次读取时的视图,就是T3时刻产生的一致性视图

==RR和RC的版本链是一样的==,但是判断当前数据可见与否用到的一致性视图不一样

在此可重复读RR隔离级别下:

  1. T4时刻时事务300第一次读时的分析和结果与RC都一样,可以见上文分析与结果
  2. T7时刻时事务400第一次读时的分析和结果与RC都一样,可以见上文分析与结果
  3. T10时刻时事务300第二次读时的一致性视图和第一次读时的一样,所以此时到底读取到什么数据就要重新分析了

此时 (用的是第一次读时生成的一致性视图ReadView

  • m_ids 是[100,200]: 当前活跃的读写事务的事务id列表
  • min_trx_id 是 100: m_ids的最小值
  • max_trx_id 是 201: m_ids的最大值+1

此时的版本链是:

img

当前数据的事务id是200,数据是E,在当前事务活跃列表里面,所以数据不可见,根据回滚指针找到上个版本,发现事务id是300,当前事务也是300,可见,所以读取的数据是D

总结:当隔离级别是可重复读RR的情况下,每次读都会用第一次读取数据时生成的一致性视图(ReadView)

  • T4时刻 事务300读取到的数据是小杰
  • T7时刻 事务400读取到的数据是B
  • T10时刻 事务300读取到的数据是D

2、关于间隙锁与Next-Key Lock

1、简介

科普一下Innodb的三种行锁的算法:

  • 行锁:就是对单条记录上锁。记录锁(Record Lock)
  • 间隙锁:gap锁,又称为间隙锁。存在的主要目的就是为了防止在可重复读的事务级别下,出现幻读问题
  • Next-key Lock:Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁。innodb默认的锁就是Next-Key locks。是行锁和gap锁的组合。
    • 在可重复读的事务级别下面,普通的select读的是快照,不存在幻读情况,但是如果加上for update的话,读取是已提交事务数据,Next-key Lock锁保证for update情况下,不出现幻读。
2、那么gap锁到底是如何加锁的呢?

假如是for update级别操作,先看看几条总结的何时加锁的规则:

  • 唯一索引
    • 精确等值检索:Next-Key Locks就退化为记录锁,不会加gap锁
    • 范围检索会锁住where条件中相应的范围,范围中的记录以及间隙,换言之就是加上记录锁和 gap 锁(至于区间是多大稍后讨论)。
    • 不走索引检索:全表间隙加gap锁、全表记录加记录锁——>升级为表锁
  • 非唯一索引
    • 精确等值检索:Next-Key Locks会对间隙加gap锁(至于区间是多大稍后讨论),以及对应检索到的记录加记录锁
    • 范围检索会锁住where条件中相应的范围,范围中的记录以及间隙,换言之就是加上记录锁和gap 锁(至于区间是多大稍后讨论)。
    • 非索引检索全表间隙gap lock,全表记录record lock
3、相关实例
1、建表
1
2
3
4
5
6
7
8
9
10
11
12
13
create table gap_table
(
letter varchar(2) default '' not null
primary key,
num int null
);

create index gap_table_num_uindex
on gap_table (num);

INSERT INTO gap_table (letter, num) VALUES ('d', 3);
INSERT INTO gap_table (letter, num) VALUES ('g', 6);
INSERT INTO gap_table (letter, num) VALUES ('j', 8);

表结构:主键简单点是字符,属性列只有一个数字,是非唯一索引。

2、无gap锁

假如没有gap锁,也就是把事务级别调到读已提交,执行以下两个session

session1 session2
select * from gap_table where num=6 for update;结果是一条
INSERT INTO gap_table (letter, num) VALUES (’’, 6);
select * from gap_table where num=6 for update;结果是二条,出现幻读
3、非唯一索引等值检索gap锁

假如有gap锁,演示一个非唯一索引等值检索gap锁。也就是把事务级别调到可重复读,执行以下两个session

session1 session2
select * from gap_table where num=6 for update;结果是一条。
INSERT INTO gap_table (letter, num) VALUES (’’, 6);gap锁住间隙,阻塞无法插入数据。
select * from gap_table where num=6 for update;结果是一条。不出现幻读
4、唯一索引(主键)范围检索gap锁

假如有gap锁,演示一个唯一索引范围检索gap锁。也就是把事务级别调到可重复读,执行以下两个session

session1 session2
select * from gap_table where letter>’d’ for update;结果是两条。
INSERT INTO gap_table (letter, num) VALUES (‘z’, 10);gap锁住间隙,阻塞无法插入数据。
select * from gap_table where letter>’d’ for update;结果是两条。不出现幻读
4、gap锁是如何锁区间?

经过上面的演示可以知道gap锁的基本作用就是保证可重复读的情况下不出现幻读

那么还有一点就是gap是按照什么原则进行锁的呢?

要了解gap锁的原则,需要先了解innodb中索引树的结构。

下面一张图片描述了在innodb中,索引的数据结构是如何组织的:

img

从上面的图片可以看出,索引结构分为主索引树和辅助索引树,辅助索引树的叶子节点中包含了主键数据,主键数据影响着叶子节点的排序,gap锁的关键就是锁住索引树的叶子节点之间的间隙,不让新的记录插入到间隙之中,说起来可能拗口,下面画图分析。

1、非唯一索引gap锁原则分析

假如还是使用一开始演示的表结构和数据,那么当前的辅助索引树(数字列)叶子节点的排序结构应该如下。

img

假如执行以下sql的话:

1
INSERT INTO gap_table (letter, num) VALUES ('k', 6);

辅助索引树的叶子节点结构变为以下图片结构,k大于g,所以(6,k)排在后面,我们先把(6,k)这条数据删除,方便后面演示:

img

了解了以上的规则,我们进行实际操作演示gap锁区间原则,从而推测锁住哪些区间。

情况1

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行成功

1
INSERT INTO gap_table (letter, num) VALUES ('a', 3);

按照排序规则,叶子节点插入结构如下:

在这里插入图片描述

情况2

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行失败

1
INSERT INTO gap_table (letter, num) VALUES ('e', 3);

按照排序规则,叶子节点应该插入如下地方,但是因为区间被锁插入失败。

img

情况3

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行失败

1
INSERT INTO gap_table (letter, num) VALUES ('h', 6);

按照排序规则,叶子节点应该插入如下地方,但是因为区间被锁插入失败。

在这里插入图片描述

情况4

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行失败

1
INSERT INTO gap_table (letter, num) VALUES ('h', 7);

按照排序规则,叶子节点应该插入如下地方,但是因为区间被锁插入失败。

在这里插入图片描述

情况5

分别有两个session,session1执行以下语句:

1
select * from gap_table where num=6 for update

session2执行以下sql,执行成功

1
INSERT INTO gap_table (letter, num) VALUES ('h', 9);

按照排序规则,插入在未锁区间就能插入成功。

在这里插入图片描述

1、总结

当session1执行以下语句:

1
select * from gap_table where num=6 for update

锁住的区间如图所示。

在这里插入图片描述

按照B+索引树排序规则,计算好叶子节点插入位置时,在被gap锁住的区间段内,不能插入任何数据,只有在gap锁释放时才能进行插入。

在上面的各种情况中锁住的区间其实是(3,d)到(6,g)和(6,g)到(8,j),落到这个区间段的叶子节点都是无法插入的。主键也作为一个信息参与到叶子节点的排序规则中。这里面边界都是开区间,插入(3,d),(8,j)的数据会报错主键重复而不是lock等待超时。

2、唯一索引或者非唯一索引范围检索gap锁原则分析

另一种会出现gap锁的情况就是使用索引时,用到范围检索,就会出现gap 锁。

使用以下表结构:

1
2
3
4
5
6
7
8
9
10
11
create table gap_tbz
(
id int default 0 not null
primary key,
name varchar(11) null
);

INSERT INTO test.gap_tbz (id, name) VALUES (1, 'a');
INSERT INTO test.gap_tbz (id, name) VALUES (5, 'h');
INSERT INTO test.gap_tbz (id, name) VALUES (8, 'm');
INSERT INTO test.gap_tbz (id, name) VALUES (11, 'ds');

情况1

分别有两个session,session1执行以下语句:

1
select * from gap_tbz where id > 5 for update;

session2执行以下sql,执行失败

1
insert into gap_tbz values(6,'cc');

按照排序规则,这里应该是在主键索引树检索,叶子节点插入结构如下。由于session1执行了范围的for update sql语句,因此范围内添加了gap锁,gap锁的区间是id在(5,+无限)

在这里插入图片描述

当执行插入的id范围在5之前,如下sql,能够执行成功

1
insert into gap_tbz values(4,'cc');

但若此时将session1的sql修改为:

1
select * from gap_tbz where id >= 5 for update;

此时gap锁的区间为id区间(1,5)和(5,+无限),也就是说以下sql会执行失败:

1
insert into gap_tbz values(4,'cc');
  • 这也同时反映了间隙锁Gap的危害:在数据行2/3/4进行插入操作不会影响session1的sql的执行效果,但是此时数据行2/3/4却因为被加上了间隙锁而导致不能实现插入操作

情况2

分别有两个session,session1执行以下语句:

1
select * from gap_tbz where id > 5 and id < 11 for update;

session2执行以下sql,执行失败

1
2
3
4
5
6
7
#以下报错 lock等待超时
insert into gap_tbz values(11,'cc');

#以下报错 主键重复
insert into gap_tbz values(5,'cc');

#从两种报错来看也可以看出gap锁区间是左开右闭

按照排序规则,这里应该是在主键索引树检索,由于session1执行了范围的for update sql语句,因此范围内添加了gap锁,gap锁的区间是id在(5,11]**,唯一索引gap锁区间是左开右闭**。

2、总结

当session1执行以下语句:

1
select * from gap_tbz where id > 5 for update;

此时gap锁的区间是id在(5,+无限)

在这里插入图片描述

当session1执行以下语句:

1
select * from gap_tbz where id >= 5 for update;

此时gap锁的区间为id区间(1,5)和(5,+无限)

当session1执行以下语句:

1
select * from gap_tbz where id > 5 and id < 11 for update;

此时gap锁的区间是id在(5,11],唯一索引gap锁区间是左开右闭

3、思考(在session1执行for update语句)

假如条件是一个非索引列,那么如何处理?

  • 假如是非索引列,那么将会全表间隙加上gap锁。
  • 如果此时整个锁没有数据的话,那么整个表都会加上一个间隙锁

条件是唯一索引等值检索且记录不存在的情况,会使用gap lock?

  • 我们要考虑,gap lock是防止幻读,那么尝试思考,使用唯一索引所谓条件查找数据for update,如果对应的记录不存在的话,是无法使用行锁的。
  • 这时候,会使用gap lock来锁住区间,保证记录不会插入,防止出现幻读。

使用了间隙锁可以在可重复读的隔离级别下解决幻读问题,那么间隙锁Gap在并发情况下很容易造成死锁问题

  • 字段id为不是主键,可以查询出多个值,数据库没有id=10这些数据

  • 事务A、事务B同时执行select * from table where id = 10 for update 语句

  • 事务A执行时SQL语句时加上了间隙锁,同时添加数据一条数据

    1
    insert into table(id,name) values(10,"zs"); 

    此时事务A对除了(10,”zs”)这条数据以外的数据都设置了间隙锁

  • 事务B 执行select * from table where id = 10 for update 语句查出(10,”zs”)这条数据,并对除了(10,”zs”)这条数据以外的数据都设置了间隙锁

  • 此时事务A如果去添加数据一条其他数据,如(10,”ls”)或者事务B如果去添加数据一条其他数据,如(10,”ww”)

  • 事务A与事务B都会因为对方的间隙锁而导致插入阻塞,从而导致死锁。

  • 使用Next-Key Lock 可以解决以上问题:因为使用Next-Key Lock可以把(10,”zs”)这条数据也加上锁

4、总结

间隙锁的危害

  1. 在数据行2/3/4(间隙)进行插入操作不会影响session1的sql的执行效果的情况下,此时数据行2/3/4(间隙)却因为被加上了间隙锁而导致不能实现插入操作
  2. 在使用for update的情况下,会有大面积的间隙锁产生,此时其他连接不能操作当前数据
  3. 死锁问题

Next-Key Lock的作用

  1. 非唯一索引精确等值检索时,Next-Key Locks会对间隙加gap锁,以及对应检索到的记录加记录锁。防止出现幻读问题
  2. 解决死锁问题
  3. Next-Key Locks是在存储引擎innodb、事务级别在可重复读的情况下使用的数据库锁。

补充:关于Next-Key Lock加的记录锁是读锁还是写锁的问题

经过测试得:在Mysql8,存储引擎为InnoDB的情况下

  • 如果使用的select……lock in share mode进行搜索,使用的是共享锁,则记录锁是读锁
  • 如果使用的select……for update进行搜索,使用的是独占锁,则记录锁是写锁

16、常用SQL技巧

1、常见通用的Join查询

1、SQL执行顺序

编写顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SELECT DISTINCT
<select list>
FROM
<left_table> <join_type>
JOIN
<right_table> ON <join_condition>
WHERE
<where_condition>
GROUP BY
<group_by_list>
HAVING
<having_condition>
ORDER BY
<order_by_condition>
LIMIT
<limit_params>

执行顺序(随着Mysql版本的更新换代,其优化器也在不断的升级,优化器会分析不同执行顺序产生的性能消耗不同而动态调整执行顺序)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FROM	<left_table>

ON <join_condition>

<join_type> JOIN <right_table>

WHERE <where_condition>

GROUP BY <group_by_list>

HAVING <having_condition>

SELECT DISTINCT <select list>

ORDER BY <order_by_condition>

LIMIT <limit_params>

img

2、Join图

img

共有与独有(理解):

  • 共有:满足 a.deptid = b.id 的叫共有
  • A独有:A 表中所有不满足 a.deptid = b.id 连接关系的数据

同时参考 join 图

1、案例准备
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
CREATE TABLE `t_dept` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`deptName` VARCHAR(30) DEFAULT NULL,
`address` VARCHAR(40) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
CREATE TABLE `t_emp` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`name` VARCHAR(20) DEFAULT NULL,
`age` INT(3) DEFAULT NULL,
`deptId` INT(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_dept_id` (`deptId`)
#CONSTRAINT `fk_dept_id` FOREIGN KEY (`deptId`) REFERENCES `t_dept` (`id`)
) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

INSERT INTO t_dept(deptName,address) VALUES('华山','华山');
INSERT INTO t_dept(deptName,address) VALUES('丐帮','洛阳');
INSERT INTO t_dept(deptName,address) VALUES('峨眉','峨眉山');
INSERT INTO t_dept(deptName,address) VALUES('武当','武当山');
INSERT INTO t_dept(deptName,address) VALUES('明教','光明顶');
INSERT INTO t_dept(deptName,address) VALUES('少林','少林寺');
INSERT INTO t_emp(NAME,age,deptId) VALUES('风清扬',90,1);
INSERT INTO t_emp(NAME,age,deptId) VALUES('岳不群',50,1);
INSERT INTO t_emp(NAME,age,deptId) VALUES('令狐冲',24,1);
INSERT INTO t_emp(NAME,age,deptId) VALUES('洪七公',70,2);
INSERT INTO t_emp(NAME,age,deptId) VALUES('乔峰',35,2);
INSERT INTO t_emp(NAME,age,deptId) VALUES('灭绝师太',70,3);
INSERT INTO t_emp(NAME,age,deptId) VALUES('周芷若',20,3);
INSERT INTO t_emp(NAME,age,deptId) VALUES('张三丰',100,4);
INSERT INTO t_emp(NAME,age,deptId) VALUES('张无忌',25,5);
INSERT INTO t_emp(NAME,age,deptId) VALUES('韦小宝',18,null);
2、7种JOIN
  1. A、B两表共有(inner join)

    1
    select * from t_emp a inner join t_dept b on a.deptId = b.id;
  2. A、B两表共有 + A的独有(a left join b)

    1
    select * from t_emp a left join t_dept b on a.deptId = b.id;
  3. A、B两表共有 + B的独有(a right join b)

    1
    select * from t_emp a right join t_dept b on a.deptId = b.id;
  4. A的独有(a left join b + on a.Id = b.id + where b.id is null)

    1
    select * from t_emp a left join t_dept b on a.deptId = b.id where b.id is null; 
  5. B的独有(a right join b + on a.Id = b.id where a.Id is null)

    1
    select * from t_emp a right join t_dept b on a.deptId = b.id where a.deptId is null;
  6. AB全有(a full outer join b)

    1
    2
    3
    4
    5
    6
    7
    8
    -- MySQL Full Join的实现 因为MySQL不支持FULL JOIN,下面是替代方法:
    -- left join + union(可去除重复数据)+ right join

    SELECT * FROM t_emp A LEFT JOIN t_dept B ON A.deptId = B.id

    UNION

    SELECT * FROM t_emp A RIGHT JOIN t_dept B ON A.deptId = B.id

    这里因为要联合的缘故,不能考虑到小表驱动大表的情况。只能用right join。要保证查询出来的数字要一致。

  7. A的独有 + B的独有(a full outer join b + on a.Id = b.id + where a.Id is null or b,id = null)

    1
    2
    3
    4
    5
    select * FROM t_emp A LEFT JOIN t_dept B ON A.deptId = B.id WHERE B.`id` IS NULL

    UNION

    SELECT * FROM t_emp A RIGHT JOIN t_dept B ON A.deptId = B.id WHERE A.`deptId` IS NULL;
3、子查询与join两者区别

思想上的区别:

  • 子查询理解:
    1. 先知道需要查询并将数据拿出来(若from 后的表也是一个子查询结果)。
    2. 在去寻找满足判断条件的数据(where,on,having 后的参数等)。而这些查询条件通常是通过子查询获得的。
    3. 子查询是一种根据结果找条件的倒推的顺序。比较好理解与判断
  • join理解:
    1. 执行完第一步后的结果为一张新表。
    2. 在将新表与 t_emp 进行下一步的 left join 关联。
    3. 先推出如何获得条件,再像算数题一样一步一步往下 join。可以交换顺序,但只能是因为条件间不相互关联时才能交换顺序。
    4. join 比 子查询难一点
    5. join 能用到索引,但是子查询出来的表会使索引失效。

2、正则表达式使用

正则表达式(Regular Expression)是指一个用来描述或者匹配一系列符合某个句法规则的字符串的单个字符串。

符号 含义
^ 在字符串开始处进行匹配
$ 在字符串末尾处进行匹配
. 匹配任意单个字符, 包括换行符
[…] 匹配出括号内的任意字符
[^…] 匹配不出括号内的任意字符
a* 匹配零个或者多个a(包括空串)
a+ 匹配一个或者多个a(不包括空串)
a? 匹配零个或者一个a
a1|a2 匹配a1或a2
a(m) 匹配m个a
a(m,) 至少匹配m个a
a(m,n) 匹配m个a 到 n个a
a(,n) 匹配0到n个a
(…) 将模式元素组成单一元素
1
2
3
4
5
select * from emp where name regexp '^T';

select * from emp where name regexp '2$';

select * from emp where name regexp '[uvw]';

3、MySQL 常用函数

数字函数

函数名称 作 用
ABS 求绝对值
SQRT 求二次方根
MOD 求余数
CEIL 和 CEILING 两个函数功能相同,都是返回不小于参数的最小整数,即向上取整
FLOOR 向下取整,返回值转化为一个BIGINT
RAND 生成一个0~1之间的随机数,传入整数参数是,用来产生重复序列
ROUND 对所传参数进行四舍五入
SIGN 返回参数的符号
POW 和 POWER 两个函数的功能相同,都是所传参数的次方的结果值
SIN 求正弦值
ASIN 求反正弦值,与函数 SIN 互为反函数
COS 求余弦值
ACOS 求反余弦值,与函数 COS 互为反函数
TAN 求正切值
ATAN 求反正切值,与函数 TAN 互为反函数
COT 求余切值

字符串函数

函数名称 作 用
LENGTH 计算字符串长度函数,返回字符串的字节长度
CONCAT 合并字符串函数,返回结果为连接参数产生的字符串,参数可以使一个或多个
INSERT 替换字符串函数
LOWER 将字符串中的字母转换为小写
UPPER 将字符串中的字母转换为大写
LEFT 从左侧字截取符串,返回字符串左边的若干个字符
RIGHT 从右侧字截取符串,返回字符串右边的若干个字符
TRIM 删除字符串左右两侧的空格
REPLACE 字符串替换函数,返回替换后的新字符串
SUBSTRING 截取字符串,返回从指定位置开始的指定长度的字符换(下标从1开始)
REVERSE 字符串反转(逆序)函数,返回与原始字符串顺序相反的字符串

日期函数

函数名称 作 用
CURDATE 和 CURRENT_DATE 两个函数作用相同,返回当前系统的日期值
CURTIME 和 CURRENT_TIME 两个函数作用相同,返回当前系统的时间值
NOW 和 SYSDATE 两个函数作用相同,返回当前系统的日期和时间值
MONTH 获取指定日期中的月份
MONTHNAME 获取指定日期中的月份英文名称
DAYNAME 获取指定曰期对应的星期几的英文名称
DAYOFWEEK 获取指定日期对应的一周的索引位置值
WEEK 获取指定日期是一年中的第几周,返回值的范围是否为 0〜52 或 1〜53
DAYOFYEAR 获取指定曰期是一年中的第几天,返回值范围是1~366
DAYOFMONTH 获取指定日期是一个月中是第几天,返回值范围是1~31
YEAR 获取年份,返回值范围是 1970〜2069
TIME_TO_SEC 将时间参数转换为秒数
SEC_TO_TIME 将秒数转换为时间,与TIME_TO_SEC 互为反函数
DATE_ADD 和 ADDDATE 两个函数功能相同,都是向日期添加指定的时间间隔
DATE_SUB 和 SUBDATE 两个函数功能相同,都是向日期减去指定的时间间隔
ADDTIME 时间加法运算,在原始时间上添加指定的时间
SUBTIME 时间减法运算,在原始时间上减去指定的时间
DATEDIFF 获取两个日期之间间隔,返回参数 1 减去参数 2 的值
DATE_FORMAT 格式化指定的日期,根据参数返回指定格式的值
WEEKDAY 获取指定日期在一周内的对应的工作日索引

聚合函数

函数名称 作用
MAX 查询指定列的最大值
MIN 查询指定列的最小值
COUNT 统计查询结果的行数
SUM 求和,返回指定列的总和
AVG 求平均值,返回指定列数据的平均值

17、MySql中常用工具

1、mysql

该mysql不是指mysql服务,而是指mysql的客户端工具。

语法:

1
mysql [options] [database]
1、连接选项

参数[options]:

  • -u, –user=name:指定用户名
  • -p, –password[=name]:指定密码
  • -h, –host=name:指定服务器IP或域名
  • -P, –port=#:指定连接端口

示例:

1
2
3
mysql -h 127.0.0.1 -P 3306 -u root -p

mysql -h127.0.0.1 -P3306 -uroot -p2143
2、执行选项
1
2
# 执行SQL语句并退出
-e, --execute=name

此选项可以在Mysql客户端执行SQL语句,而不用连接到MySQL数据库再执行,对于一些批处理脚本,这种方式尤其方便。

示例:

1
mysql -uroot -p2143 db01 -e "select * from tb_book";

1555325632715

2、mysqladmin

mysqladmin 是一个执行管理操作的客户端程序。可以用它来==检查服务器的配置和当前状态==、==创建并删除数据库==等。也是对于一些批处理脚本,这种方式尤其方便。

可以通过 : mysqladmin --help 指令查看帮助文档

1555326108697

示例 :

1
2
3
4
5
mysqladmin -uroot -p2143 create 'test01';  

mysqladmin -uroot -p2143 drop 'test01';

mysqladmin -uroot -p2143 version;

3、mysqlbinlog

由于服务器生成的二进制日志文件以二进制格式保存,所以如果想要检查这些文本的文本格式,就会使用到mysqlbinlog 日志管理工具。

语法 :

1
mysqlbinlog [options]  log-files1 log-files2 ...

选项[options]:

  • -d, –database=name:指定数据库名称,只列出指定的数据库相关操作。
  • -o, –offset=#:忽略掉日志中的前n行命令。
  • -r,–result-file=name:将输出的文本格式日志输出到指定文件。
  • -s, –short-form:显示简单格式, 省略掉一些信息。
  • –start-datatime=date1 –stop-datetime=date2:指定日期间隔内的所有日志。
  • –start-position=pos1 –stop-position=pos2:指定位置间隔内的所有日志。

4、mysqldump

mysqldump 客户端工具用来==备份数据库==或==在不同数据库之间进行数据迁移==。

备份内容包含创建表,及插入表的SQL语句。

语法 :

1
2
3
4
5
mysqldump [options] db_name [tables]

mysqldump [options] --database/-B db1 [db2 db3...]

mysqldump [options] --all-databases/-A
1、连接选项

参数[options]:

  • -u, –user=name:指定用户名
  • -p, –password[=name]:指定密码
  • -h, –host=name:指定服务器IP或域名
  • -P, –port=#:指定连接端口
2、输出内容选项

参数[options]:

  • –add-drop-database:在每个数据库创建语句前加上 Drop database 语句(如果数据库存在就删除旧数据库)
  • –add-drop-table:在每个表创建语句前加上 Drop table 语句 , 默认开启 ; 不开启 (--skip-add-drop-table)
  • -n, –no-create-db:不包含数据库的创建语句
  • -t, –no-create-info:不包含数据表的创建语句
  • -d –no-data:不包含数据
  • -T, –tab=name:自动生成两个文件:
    • 一个.sql文件,创建表结构的语句;
    • 一个.txt文件,数据文件,相当于select into outfile

示例:

1
2
3
mysqldump -uroot -p2143 db01 tb_book --add-drop-database --add-drop-table > a

mysqldump -uroot -p2143 -T /tmp test city

image-20210902222238916

5、mysqlimport/source

mysqlimport 是客户端数据导入工具,用来导入mysqldump 加 -T 参数后导出的文本文件

语法:

1
mysqlimport [options]  db_name  textfile1  [textfile2...]

示例:

1
mysqlimport -uroot -p2143 test /tmp/city.txt

如果需要导入sql文件,可以使用mysql中的source 指令 :

1
source /root/tb_book.sql

6、mysqlshow

mysqlshow 客户端对象查找工具,用来很快地查找存在哪些数据库、数据库中的表、表中的列或者索引

语法:

1
mysqlshow [options] [db_name [table_name [col_name]]]

参数[options]:

  • –count:显示数据库及表的统计信息(数据库,表 均可以不指定)
  • -i:显示指定数据库或者指定表的状态信息

示例:

1
2
3
4
5
6
7
8
#查询每个数据库的表的数量及表中记录的数量
mysqlshow -uroot -p2143 --count

#查询test库中每个表中的字段书,及行数
mysqlshow -uroot -p2143 test --count

#查询test库中book表的详细情况
mysqlshow -uroot -p2143 test book --count

18、Mysql 日志

在任何一种数据库中,都会有各种各样的日志,记录着数据库工作的方方面面,以帮助数据库管理员追踪数据库曾经发生过的各种事件。MySQL 也不例外。

在 MySQL 中,有 4 种不同的日志,分别是:

  • 错误日志
  • 二进制日志(BINLOG 日志)
  • 查询日志
  • 慢查询日志

这些日志记录着数据库在不同方面的踪迹。

全局查询日志:

配置启用:

在mysql的my.cnf中,设置如下:

1
2
3
4
5
6
#开启
general_log=1
# 记录日志文件的路径
general_log_file=/path/logfile
#输出格式
log_output=FILE

编码启用:

1
2
3
4
5
6
7
set global general_log=1;

-- 全局日志可以存放到日志文件中,也可以存放到Mysql系统表中。存放到日志中性能更好一些,存储到表中
set global log_output='TABLE';

-- 此后 ,你所编写的sql语句,将会记录到mysql库里的general_log表,可以用下面的命令查看
select * from mysql.general_log;

1、错误日志

错误日志是 MySQL 中最重要的日志之一,它记录了当 mysqld 启动和停止时,以及服务器在运行过程中发生任何严重错误时的相关信息。当数据库出现任何故障导致无法正常使用时,可以首先查看此日志

该日志是默认开启的 , 默认存放目录为 mysql 的数据目录(var/lib/mysql), 默认的日志文件名为 hostname.err(hostname是主机名)。

查看日志位置指令:

1
show variables like 'log_error%';

1553993244446

查看日志内容:

1
tail -f /var/lib/mysql/xaxh-server.err

1553993537874

2、二进制日志

1、概述

二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但是不包括数据查询语句

此日志对于灾难时的数据恢复起着极其重要的作用,MySQL的主从复制, 就是通过该binlog实现的。

二进制日志,默认情况下是没有开启的,需要到MySQL的配置文件中开启,并配置MySQL日志的格式

配置文件位置 : /usr/my.cnf

日志存放位置:配置时,给定了文件名但是没有指定路径,日志默认写入Mysql的数据目录(var/lib/mysql)

1
2
3
4
5
#配置开启binlog日志, 日志的文件前缀为 mysqlbin -----> 生成的文件名如 : mysqlbin.000001,mysqlbin.000002
log_bin=mysqlbin

#配置二进制日志的格式
binlog_format=STATEMENT
2、日志格式
1、STATEMENT

该日志格式在日志文件中记录的都是==SQL语句(statement)==,每一条对数据进行修改的SQL都会记录在日志文件中,通过Mysql提供的mysqlbinlog工具,可以清晰的查看到每条语句的文本。

主从复制的时候,从库(slave)会将日志解析为原文本,并在从库重新执行一次。

2、ROW

该日志格式在日志文件中记录的是==每一行的数据变更==,而不是记录SQL语句。

比如,执行SQL语句 : update tb_book set status=’1’:

  • 如果是STATEMENT 日志格式,在日志中会记录一行SQL文件;
  • 如果是ROW,由于是对全表进行更新,也就是每一行记录都会发生变更,ROW 格式的日志中会记录每一行的数据变更。
3、MIXED

这是目前MySQL默认的日志格式,即混合了STATEMENT 和 ROW两种格式。

默认情况下采用STATEMENT,但是在一些特殊情况下采用ROW来进行记录。MIXED 格式能尽量利用两种模式的优点,而避开他们的缺点。

3、日志读取

由于日志以二进制方式存储,不能直接读取,需要用mysqlbinlog工具来查看,语法如下 :

1
mysqlbinlog log-file;
1、查看STATEMENT格式日志

执行插入语句:

1
insert into tb_book values(null,'Lucene','2088-05-01','0');

查看日志文件:

1554079717375

  • mysqlbin.index:该文件是日志索引文件 , 记录日志的文件名;
  • mysqlbing.000001:日志文件

查看日志内容:

1
mysqlbinlog mysqlbing.000001;

1554080016778

2、查看ROW格式日志

配置:

1
2
3
4
5
#配置开启binlog日志, 日志的文件前缀为 mysqlbin -----> 生成的文件名如 : mysqlbin.000001,mysqlbin.000002
log_bin=mysqlbin

#配置二进制日志的格式
binlog_format=ROW

插入数据:

1
insert into tb_book values(null,'SpringCloud实战','2088-05-05','0');

如果日志格式是 ROW , 直接查看数据 , 是查看不懂的;可以在mysqlbinlog 后面加上参数 -vv

1
mysqlbinlog -vv mysqlbin.000002 

1554095452022

4、日志删除

对于比较繁忙的系统,由于每天生成日志量大 ,这些日志如果长时间不清楚,将会占用大量的磁盘空间

下面我们将会讲解几种删除日志的常见方法 :

1、方式一:Reset Master

通过 Reset Master 指令删除全部 binlog 日志,删除之后,日志编号,将从 xxxx.000001重新开始 。

查询之前 ,先查询下日志文件:

1554118609489

执行删除日志指令:

1
Reset Master

执行之后, 查看日志文件:

1554118675264

2、方式二:purge master logs to ‘mysqlbin.*’

执行指令 purge master logs to 'mysqlbin.******' ,该命令将删除 ****** 编号之前的所有日志。

3、方式三:purge master logs before ‘yyyy-mm-dd hh24:mi:ss’

执行指令 purge master logs before 'yyyy-mm-dd hh24:mi:ss' ,该命令将删除日志为 “yyyy-mm-dd hh24:mi:ss” 之前产生的所有日志 。

4、方式四:–expire_logs_days=#

设置参数 --expire_logs_days=# ,此参数的含义是设置日志的过期天数, 过了指定的天数后日志将会被自动删除,这样将有利于减少DBA 管理日志的工作量

配置如下:

1554125506938

3、查询日志

查询日志中记录了客户端的所有操作语句,而二进制日志不包含查询数据的SQL语句。

默认情况下, 查询日志是未开启的。如果需要开启查询日志,可以设置以下配置:

1
2
3
4
5
#该选项用来开启查询日志 , 可选值 : 0 或者 1 ; 0 代表关闭, 1 代表开启 
general_log=1

#设置日志的文件名 , 如果没有指定, 默认的文件名为 host_name.log,默认生成在mysql的数据目录下(var/lib/mysql)
general_log_file=file_name

在 mysql 的配置文件 /usr/my.cnf 中配置如下内容:

1554128184632

配置完毕之后,在数据库执行以下操作:

1
2
3
4
select * from tb_book;
select * from tb_book where id = 1;
update tb_book set name = 'lucene入门指南' where id = 5;
select * from tb_book where id < 8;

执行完毕之后, 再次来查询日志文件:

1554128089851

4、慢查询日志

慢查询日志记录了所有执行时间超过参数 long_query_time 设置值并且扫描记录数不小于 min_examined_row_limit 的所有的SQL语句的日志

long_query_time 默认为 10 秒,最小为 0, 精度可以到微秒。

1、文件位置和格式

慢查询日志默认是关闭的

1
SHOW VARIABLES LIKE '%slow_query_log%';

img

使用set global slow_query_log=1开启了慢查询日志只对当前数据库生效,

如果MySQL重启后则会失效。

img

img

全局变量设置,对当前连接不影响

img

对当前连接立刻生效

img

如果要永久生效,可以通过两个参数来控制慢查询日志 :

1
2
3
4
5
6
7
8
9
10
11
# 该参数用来控制慢查询日志是否开启, 可取值: 1 和 0 , 1 代表开启, 0 代表关闭
slow_query_log=1

# 该参数用来指定慢查询日志的文件名
slow_query_log_file=slow_query.log

# 该选项用来配置查询的时间限制, 超过这个时间将认为值慢查询, 将需要进行日志记录, 默认10s
# 假如运行时间正好等于long_query_time的情况,并不会被记录下来。也就是说,在mysql源码里是判断大于long_query_time,而非大于等于。
long_query_time=10

log_output=FILE

当然,如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响。慢查询日志支持将日志记录写入文件。

2、日志的读取

和错误日志、查询日志一样,慢查询日志记录的格式也是纯文本,可以被直接读取。

  1. 查询long_query_time 的值。

    1554130333472

  2. 执行查询操作

    1
    select id, title,price,num ,status from tb_item where id = 1;

    1554130448709

    由于该语句执行时间很短,为0s , 所以不会记录在慢查询日志中。

    1
    select * from tb_item where title like '%阿尔卡特 (OT-927) 炭黑 联通3G手机 双卡双待165454%' ;

    1554130532577

    该SQL语句 , 执行时长为 26.77s ,超过10s , 所以会记录在慢查询日志文件中。

    查询当前系统中有多少条慢查询记录:

    1
    show global status like '%Slow_queries%';

    img

  3. 查看慢查询日志文件

    • 直接通过cat\tail 指令查询该日志文件:

      1554130669360

    • 如果慢查询日志内容很多, 直接查看文件,比较麻烦, 这个时候可以借助于mysql自带的 mysqldumpslow 工具, 来对慢查询日志进行分类汇总。

      1554130856485


19、Mysql复制

1、复制概述

复制是指将主数据库的DDL 和 DML 操作通过二进制日志传到从库服务器中,然后在从库上对这些日志重新执行(也叫重做),从而使得从库和主库的数据保持同步。

MySQL支持一台主库同时向多台从库进行复制, 从库同时也可以作为其他从服务器的主库,实现链状复制。

2、复制原理

MySQL 的主从复制原理如下:

1554423698190

从上层来看,复制分成三步:

  • Master 主库在事务提交时,会把数据变更作为时间 Events 记录在二进制日志文件 Binlog 中。

  • 主库推送二进制日志文件 Binlog 中的日志事件到从库的中继日志 Relay Log 。

  • slave重做中继日志中的事件,将改变反映它自己的数据。

3、复制优势

MySQL 复制的有点主要包含以下三个方面:

  • 主库出现问题,可以快速切换到从库提供服务

  • 可以在从库上执行查询操作,从主库中更新,实现读写分离,降低主库的访问压力

  • 可以在从库中执行备份,以避免备份期间影响主库的服务

4、搭建步骤

1、master
  1. 在master 的配置文件(/usr/my.cnf)中,配置如下内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    #mysql 服务ID,保证整个集群环境中唯一
    server-id=1

    #mysql binlog 日志的存储路径和文件名
    log-bin=/var/lib/mysql/mysqlbin

    #错误日志,默认已经开启
    #log-err

    #mysql的安装目录
    #basedir

    #mysql的临时目录
    #tmpdir

    #mysql的数据存放目录
    #datadir

    #是否只读,1 代表只读, 0 代表读写
    read-only=0

    #忽略的数据, 指不需要同步的数据库
    binlog-ignore-db=mysql

    #指定同步的数据库
    #binlog-do-db=db01
  2. 执行完毕之后,需要重启Mysql:

    1
    service mysql restart ;
  3. 创建同步数据的账户,并且进行授权操作:

    1
    2
    3
    4
    5
    -- 创建主节点的账户完成主从复制、指定对所有数据库(如果相关忽略的在mysql配置文件中配置)、主节点账户的名字、从结点的账户IP、主节点账户的密码
    grant replication slave on *.* to 'itcast'@'192.168.192.131' identified by 'itcast';

    -- 刷新权限
    flush privileges;
  4. 查看当前master结点状态信息:

    1
    show master status;

    image-20210902224311154

    字段含义:

    • File:从哪个日志文件开始推送日志文件
    • Position:从哪个位置开始推送日志
    • Binlog_Ignore_DB:指定不需要同步的数据库
2、slave
  1. 在 slave 端配置文件中,配置如下内容:

    1
    2
    3
    4
    5
    #mysql服务端ID,唯一
    server-id=2

    #指定binlog日志
    log-bin=/var/lib/mysql/mysqlbin
  2. 执行完毕之后,需要重启Mysql:

    1
    service mysql restart;
  3. 执行如下指令:

    1
    2
    -- 指定哪一个主节点、主节点IP地址、主节点账户名称	、主节点的账户密码、主节点二进制日志名称、主节点日志的位置
    change master to master_host= '192.168.192.130', master_user='itcast', master_password='itcast', master_log_file='mysqlbin.000001', master_log_pos=413;

    指定当前从库对应的主库的IP地址,用户名,密码,从哪个日志文件开始的那个位置开始同步推送日志。

  4. 开启同步操作:

    1
    2
    3
    start slave;

    show slave status\G;

    image-20210902224537288

  5. 停止同步操作:

    1
    stop slave;
3、验证同步操作
  1. 在主库中创建数据库,创建表,并插入数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    create database db01;

    user db01;

    create table user(
    id int(11) not null auto_increment,
    name varchar(50) not null,
    sex varchar(1),
    primary key (id)
    )engine=innodb default charset=utf8;

    insert into user(id,name,sex) values(null,'Tom','1');
    insert into user(id,name,sex) values(null,'Trigger','0');
    insert into user(id,name,sex) values(null,'Dawn','1');
  2. 在从库中查询数据,进行验证:

    在从库中,可以查看到刚才创建的数据库:

    1554544658640

    在该数据库中,查询user表中的数据:

    1554544679538


20、综合案例

1、需求分析

在业务系统中,需要记录当前业务系统的访问日志,该访问日志包含:操作人,操作时间,访问类,访问方法,请求参数,请求结果,请求结果类型,请求时长 等信息。记录详细的系统访问日志,主要便于对系统中的用户请求进行追踪,并且在系统 的管理后台可以查看到用户的访问记录。

记录系统中的日志信息,可以通过Spring 框架的AOP来实现。具体的请求处理流程,如下:

1555075760661

2、搭建案例环境

1、数据库表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
-- 创建数据库
CREATE DATABASE mysql_demo DEFAULT CHARACTER SET utf8mb4 ;

-- 品牌表
CREATE TABLE `brand` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL COMMENT '品牌名称',
`first_char` varchar(1) DEFAULT NULL COMMENT '品牌首字母',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 商品表
CREATE TABLE `item` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '商品id',
`title` varchar(100) NOT NULL COMMENT '商品标题',
`price` double(10,2) NOT NULL COMMENT '商品价格,单位为:元',
`num` int(10) NOT NULL COMMENT '库存数量',
`categoryid` bigint(10) NOT NULL COMMENT '所属类目,叶子类目',
`status` varchar(1) DEFAULT NULL COMMENT '商品状态,1-正常,2-下架,3-删除',
`sellerid` varchar(50) DEFAULT NULL COMMENT '商家ID',
`createtime` datetime DEFAULT NULL COMMENT '创建时间',
`updatetime` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品表';

-- 用户表
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(45) NOT NULL,
`password` varchar(96) NOT NULL,
`name` varchar(45) NOT NULL,
`birthday` datetime DEFAULT NULL,
`sex` char(1) DEFAULT NULL,
`email` varchar(45) DEFAULT NULL,
`phone` varchar(45) DEFAULT NULL,
`qq` varchar(32) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 操作日志表
CREATE TABLE `operation_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
`operate_class` varchar(200) DEFAULT NULL COMMENT '操作类',
`operate_method` varchar(200) DEFAULT NULL COMMENT '操作方法',
`return_class` varchar(200) DEFAULT NULL COMMENT '返回值类型',
`operate_user` varchar(20) DEFAULT NULL COMMENT '操作用户',
`operate_time` varchar(20) DEFAULT NULL COMMENT '操作时间',
`param_and_value` varchar(500) DEFAULT NULL COMMENT '请求参数名及参数值',
`cost_time` bigint(20) DEFAULT NULL COMMENT '执行方法耗时, 单位 ms',
`return_value` varchar(200) DEFAULT NULL COMMENT '返回值',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2、pom.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.7</maven.compiler.source>
<maven.compiler.target>1.7</maven.compiler.target>

<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<spring.version>5.0.2.RELEASE</spring.version>
<slf4j.version>1.6.6</slf4j.version>
<log4j.version>1.2.12</log4j.version>
<mybatis.version>3.4.5</mybatis.version>
</properties>

<dependencies> <!-- spring -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.6.8</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.16</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>${spring.version}</version>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>javax.servlet.jsp</groupId>
<artifactId>jsp-api</artifactId>
<version>2.0</version>
<scope>provided</scope>
</dependency>


<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>${log4j.version}</version>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>${mybatis.version}</version>
</dependency>

<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>1.3.0</version>
</dependency>

<dependency>
<groupId>c3p0</groupId>
<artifactId>c3p0</artifactId>
<version>0.9.1.2</version>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.5</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.9.0</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.0</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.9.0</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<port>8080</port>
<path>/</path>
<uriEncoding>utf-8</uriEncoding>
</configuration>
</plugin>
</plugins>
</build>

3、通过AOP记录操作日志

1、自定义注解

通过自定义注解,来标示方法需不需要进行记录日志,如果该方法在访问时需要记录日志,则在该方法上标示该注解既可。

1
2
3
4
5
6
@Inherited
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperateLog {
}
2、定义通知类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Component
@Aspect
public class OperateAdvice {

private static Logger log = Logger.getLogger(OperateAdvice.class);

@Autowired
private OperationLogService operationLogService;


@Around("execution(* cn.itcast.controller.*.*(..)) && @annotation(operateLog)")
public Object insertLogAround(ProceedingJoinPoint pjp , OperateLog operateLog) throws Throwable{
System.out.println(" ************************ 记录日志 [start] ****************************** ");

OperationLog op = new OperationLog();

DateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

op.setOperateTime(sdf.format(new Date()));
op.setOperateUser(DataUtils.getRandStr(8));

op.setOperateClass(pjp.getTarget().getClass().getName());
op.setOperateMethod(pjp.getSignature().getName());

//获取方法调用时传递的参数
Object[] args = pjp.getArgs();
op.setParamAndValue(Arrays.toString(args));

long start_time = System.currentTimeMillis();

//放行
Object object = pjp.proceed();

long end_time = System.currentTimeMillis();
op.setCostTime(end_time - start_time);

if(object != null){
op.setReturnClass(object.getClass().getName());
op.setReturnValue(object.toString());
}else{
op.setReturnClass("java.lang.Object");
op.setParamAndValue("void");
}

log.error(JsonUtils.obj2JsonString(op));

operationLogService.insert(op);

System.out.println(" ************************** 记录日志 [end] *************************** ");

return object;
}

}
3、方法上加注解

在需要记录日志的方法上加上注解@OperateLog。

1
2
3
4
5
6
7
8
9
10
11
@OperateLog
@RequestMapping("/insert")
public Result insert(@RequestBody Brand brand){
try {
brandService.insert(brand);
return new Result(true,"操作成功");
} catch (Exception e) {
e.printStackTrace();
return new Result(false,"操作失败");
}
}

4、日志查询后端代码实现

1、Mapper接口
1
2
3
4
5
6
7
8
9
public interface OperationLogMapper {

public void insert(OperationLog operationLog);

public List<OperationLog> selectListByCondition(Map dataMap);

public Long countByCondition(Map dataMap);

}
2、Mapper.xml 映射配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="cn.itcast.mapper.OperationLogMapper" >

<insert id="insert" parameterType="operationLog">
INSERT INTO operation_log(id,return_value,return_class,operate_user,operate_time,param_and_value,
operate_class,operate_method,cost_time)
VALUES(NULL,#{returnValue},#{returnClass},#{operateUser},#{operateTime},#{paramAndValue},
#{operateClass},#{operateMethod},#{costTime})
</insert>

<select id="selectListByCondition" parameterType="map" resultType="operationLog">
select
id ,
operate_class as operateClass ,
operate_method as operateMethod,
return_class as returnClass,
operate_user as operateUser,
operate_time as operateTime,
param_and_value as paramAndValue,
cost_time as costTime,
return_value as returnValue
from operation_log
<include refid="oplog_where"/>
limit #{start},#{size}
</select>


<select id="countByCondition" resultType="long" parameterType="map">
select count(*) from operation_log
<include refid="oplog_where"/>
</select>


<sql id="oplog_where">
<where>
<if test="operateClass != null and operateClass != '' ">
and operate_class = #{operateClass}
</if>
<if test="operateMethod != null and operateMethod != '' ">
and operate_method = #{operateMethod}
</if>
<if test="returnClass != null and returnClass != '' ">
and return_class = #{returnClass}
</if>
<if test="costTime != null">
and cost_time = #{costTime}
</if>
</where>
</sql>

</mapper>
3、Service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Service
@Transactional
public class OperationLogService {

//private static Logger logger = Logger.getLogger(OperationLogService.class);

@Autowired
private OperationLogMapper operationLogMapper;

//插入数据
public void insert(OperationLog operationLog){
operationLogMapper.insert(operationLog);
}

//根据条件查询
public PageResult selectListByCondition(Map dataMap, Integer pageNum , Integer pageSize){

if(paramMap ==null){
paramMap = new HashMap();
}
paramMap.put("start" , (pageNum-1)*rows);
paramMap.put("rows",rows);

Object costTime = paramMap.get("costTime");
if(costTime != null){
if("".equals(costTime.toString())){
paramMap.put("costTime",null);
}else{
paramMap.put("costTime",new Long(paramMap.get("costTime").toString()));
}
}

System.out.println(dataMap);


long countStart = System.currentTimeMillis();
Long count = operationLogMapper.countByCondition(dataMap);
long countEnd = System.currentTimeMillis();
System.out.println("Count Cost Time : " + (countEnd-countStart)+" ms");


List<OperationLog> list = operationLogMapper.selectListByCondition(dataMap);
long queryEnd = System.currentTimeMillis();
System.out.println("Query Cost Time : " + (queryEnd-countEnd)+" ms");


return new PageResult(count,list);

}

}
4、Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/operationLog")
public class OperationLogController {

@Autowired
private OperationLogService operationLogService;

@RequestMapping("/findList")
public PageResult findList(@RequestBody Map dataMap, Integer pageNum , Integer pageSize){
PageResult page = operationLogService.selectListByCondition(dataMap, pageNum, pageSize);
return page;
}

}

5、日志查询前端代码实现

前端代码使用 BootStrap + AdminLTE 进行布局, 使用Vuejs 进行视图层展示。

1、js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<script>
var vm = new Vue({
el: '#app',
data: {
dataList:[],
searchEntity:{
operateClass:'',
operateMethod:'',
returnClass:'',
costTime:''
},

page: 1, //显示的是哪一页
pageSize: 10, //每一页显示的数据条数
total: 150, //记录总数
maxPage:8 //最大页数
},
methods: {
pageHandler: function (page) {
this.page = page;
this.search();
},

search: function () {
var _this = this;
this.showLoading();
axios.post('/operationLog/findList.do?pageNum=' + _this.page + "&pageSize=" + _this.pageSize, _this.searchEntity).then(function (response) {
if (response) {
_this.dataList = response.data.dataList;
_this.total = response.data.total;
_this.hideLoading();
}
})
},

showLoading: function () {
$('#loadingModal').modal({backdrop: 'static', keyboard: false});
},

hideLoading: function () {
$('#loadingModal').modal('hide');
},
},

created:function(){
this.pageHandler(1);
}
});

</script>
2、列表数据展示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<tr v-for="item in dataList">
<td><input name="ids" type="checkbox"></td>
<td>{{item.id}}</td>
<td>{{item.operateClass}}</td>
<td>{{item.operateMethod}}</td>
<td>{{item.returnClass}}</td>
<td>{{item.returnValue}}</td>
<td>{{item.operateUser}}</td>
<td>{{item.operateTime}}</td>
<td>{{item.costTime}}</td>
<td class="text-center">
<button type="button" class="btn bg-olive btn-xs">详情</button>
<button type="button" class="btn bg-olive btn-xs">删除</button>
</td>
</tr>
3、分页插件
1
2
3
4
5
<div class="wrap" id="wrap">
<zpagenav v-bind:page="page" v-bind:page-size="pageSize" v-bind:total="total"
v-bind:max-page="maxPage" v-on:pagehandler="pageHandler">
</zpagenav>
</div>

6、联调测试

可以通过postman来访问业务系统,再查看数据库中的日志信息,验证能不能将用户的访问日志记录下来。

1555077276426

7、分析性能问题

系统中用户访问日志的数据量,随着时间的推移,这张表的数据量会越来越大,因此我们需要根据业务需求,来对日志查询模块的性能进行优化。

1、分页查询优化

由于在进行日志查询时,是进行分页查询,那也就意味着,在查看时,至少需要查询两次:

  1. 查询符合条件的总记录数。–> count 操作
  2. 查询符合条件的列表数据。–> 分页查询 limit 操作

通常来说,count() 都需要扫描大量的行(意味着需要访问大量的数据)才能获得精确的结果,因此是很难对该SQL进行优化操作的。如果需要对count进行优化,可以采用另外一种思路,可以==增加汇总表==,或者==redis缓存来专门记录该表对应的记录数==,这样的话,就可以很轻松的实现汇总数据的查询,而且效率很高。

但是这种统计并不能保证百分之百的准确 。对于数据库的操作,“快速、精确、实现简单”,三者永远只能满足其二,必须舍掉其中一个。

2、条件查询优化

针对于条件查询,需要==对查询条件,及排序字段建立索引==。

3、读写分离

通过==主从复制集群,来完成读写分离,使写操作走主节点, 而读操作,走从节点==。

4、MySQL服务器优化
5、应用优化

8、性能优化 - 分页

1、优化count

创建一张表用来记录日志表的总数据量:

1
2
3
create table log_counter(
logcount bigint not null
)engine = innodb default CHARSET = utf8;

在每次插入数据之后,更新该表:

1
2
3
<update id="updateLogCounter" >
update log_counter set logcount = logcount + 1
</update>

在进行分页查询时,获取总记录数,从该表中查询既可。

1
2
3
<select id="countLogFromCounter" resultType="long">
select logcount from log_counter limit 1
</select>
2、优化 limit

在进行分页时,一般通过创建覆盖索引,能够比较好的提高性能。一个非常常见,而又非常头疼的分页场景就是 “limit 1000000,10” ,此时MySQL需要搜索出前1000010 条记录后,仅仅需要返回第 1000001 到 1000010 条记录,前1000000 记录会被抛弃,查询代价非常大。

1555081714638

当点击比较靠后的页码时,就会出现这个问题,查询效率非常慢。

优化前的SQL:

1
select * from operation_log limit 3000000 , 10;

将上述SQL优化为:(使用子查询的方式进行优化)

1
select * from operation_log t , (select id from operation_log order by id limit 3000000,10) b where t.id = b.id ;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<select id="selectListByCondition" parameterType="map" resultType="operationLog">
select
id ,
operate_class as operateClass ,
operate_method as operateMethod,
return_class as returnClass,
operate_user as operateUser,
operate_time as operateTime,
param_and_value as paramAndValue,
cost_time as costTime,
return_value as returnValue
from operation_log t,

(select id from operation_log
<where>
<include refid="oplog_where"/>
</where>
order by id limit #{start},#{rows}) b where t.id = b.id
</select>

9、性能优化 - 索引

1555152703824

当根据操作人进行查询时, 查询的效率很低,耗时比较长。原因就是因为在创建数据库表结构时,并没有针对于 操作人 字段建立索引。

1
CREATE INDEX idx_user_method_return_cost ON operation_log(operate_user,operate_method,return_class,cost_time);

同上,为了查询效率高,我们也需要对 ==操作方法==、==返回值类型==、==操作耗时== 等字段进行创建索引,以提高查询效率。

1
2
3
4
5
CREATE INDEX idx_optlog_method_return_cost ON operation_log(operate_method,return_class,cost_time);

CREATE INDEX idx_optlog_return_cost ON operation_log(return_class,cost_time);

CREATE INDEX idx_optlog_cost ON operation_log(cost_time);

10、性能优化 - 排序

在查询数据时,如果业务需求中需要我们对结果内容进行了排序处理 , 这个时候,我们还需要==对排序的字段建立适当的索引==,来提高排序的效率 。

11、性能优化 - 读写分离

1、概述

在Mysql主从复制的基础上,可以使用读写分离来降低单台Mysql节点的压力,从而来提高访问效率,读写分离的架构如下:

1555235426739

对于读写分离的实现,可以通过Spring AOP 来进行动态的切换数据源,进行操作:

2、实现方式

db.properties

1
2
3
4
5
6
7
8
9
jdbc.write.driver=com.mysql.jdbc.Driver
jdbc.write.url=jdbc:mysql://192.168.142.128:3306/mysql_demo
jdbc.write.username=root
jdbc.write.password=itcast

jdbc.read.driver=com.mysql.jdbc.Driver
jdbc.read.url=jdbc:mysql://192.168.142.129:3306/mysql_demo
jdbc.read.username=root
jdbc.read.password=itcast

applicationContext-datasource.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">


<!-- 配置数据源 - Read -->
<bean id="readDataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close" lazy-init="true">
<property name="driverClass" value="${jdbc.read.driver}"></property>
<property name="jdbcUrl" value="${jdbc.read.url}"></property>
<property name="user" value="${jdbc.read.username}"></property>
<property name="password" value="${jdbc.read.password}"></property>
</bean>


<!-- 配置数据源 - Write -->
<bean id="writeDataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close" lazy-init="true">
<property name="driverClass" value="${jdbc.write.driver}"></property>
<property name="jdbcUrl" value="${jdbc.write.url}"></property>
<property name="user" value="${jdbc.write.username}"></property>
<property name="password" value="${jdbc.write.password}"></property>
</bean>


<!-- 配置动态分配的读写 数据源 -->
<bean id="dataSource" class="cn.itcast.aop.datasource.ChooseDataSource" lazy-init="true">
<property name="targetDataSources">
<map key-type="java.lang.String" value-type="javax.sql.DataSource">
<entry key="write" value-ref="writeDataSource"/>
<entry key="read" value-ref="readDataSource"/>
</map>
</property>

<property name="defaultTargetDataSource" ref="writeDataSource"/>

<property name="methodType">
<map key-type="java.lang.String">
<entry key="read" value=",get,select,count,list,query,find"/>
<entry key="write" value=",add,create,update,delete,remove,insert"/>
</map>
</property>
</bean>

</beans>

ChooseDataSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ChooseDataSource extends AbstractRoutingDataSource {

public static Map<String, List<String>> METHOD_TYPE_MAP = new HashMap<String, List<String>>();

/**
* 实现父类中的抽象方法,获取数据源名称
* @return
*/
protected Object determineCurrentLookupKey() {
return DataSourceHandler.getDataSource();
}

// 设置方法名前缀对应的数据源
public void setMethodType(Map<String, String> map) {
for (String key : map.keySet()) {
List<String> v = new ArrayList<String>();
String[] types = map.get(key).split(",");
for (String type : types) {
if (!StringUtils.isEmpty(type)) {
v.add(type);
}
}
METHOD_TYPE_MAP.put(key, v);
}
System.out.println("METHOD_TYPE_MAP : "+METHOD_TYPE_MAP);
}
}

DataSourceHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class DataSourceHandler {

// 数据源名称
public static final ThreadLocal<String> holder = new ThreadLocal<String>();

/**
* 在项目启动的时候将配置的读、写数据源加到holder中
*/
public static void putDataSource(String datasource) {
holder.set(datasource);
}

/**
* 从holer中获取数据源字符串
*/
public static String getDataSource() {
return holder.get();
}
}

DataSourceAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Aspect
@Component
@Order(-9999)
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class DataSourceAspect {

protected Logger logger = LoggerFactory.getLogger(this.getClass());

/**
* 配置前置通知,使用在方法aspect()上注册的切入点
*/
@Before("execution(* cn.itcast.service.*.*(..))")
@Order(-9999)
public void before(JoinPoint point) {

String className = point.getTarget().getClass().getName();
String method = point.getSignature().getName();
logger.info(className + "." + method + "(" + Arrays.asList(point.getArgs())+ ")");

try {
for (String key : ChooseDataSource.METHOD_TYPE_MAP.keySet()) {
for (String type : ChooseDataSource.METHOD_TYPE_MAP.get(key)) {
if (method.startsWith(type)) {
System.out.println("key : " + key);
DataSourceHandler.putDataSource(key);
break;
}
}
}
} catch (Exception e) {
e.printStackTrace();
}

}
}

通过 @Order(-9999) 注解来控制事务管理器,与该通知类的加载顺序,需要让通知类,先加载,来判定使用哪个数据源。

3、验证

在主库和从库中,执行如下SQL语句,来查看是否读的时候,从从库中读取;写入操作的时候,是否写入到主库。

1
show status like 'Innodb_rows_%' ;

1555235982584

4、原理

1555235982584

12、性能优化 - 应用优化

1、缓存

可以在业务系统中使用==redis==或者==框架本身的一级二级缓存==来做缓存,缓存一些基础性的数据,来降低关系型数据库的压力,提高访问效率。

2、全文检索

如果业务系统中的数据量比较大(达到千万级别),这个时候,如果再对数据库进行查询,特别是进行分页查询,速度将变得很慢(因为在分页时首先需要count求合计数),为了提高访问效率,这个时候,可以考虑加入==Solr== 或者 ==ElasticSearch==全文检索服务,来提高访问效率。

3、非关系数据库

也可以考虑将非核心(重要)数据,存在 ==MongoDB== 中,这样可以提高插入以及查询的效率。


0、在Linux系统安装Mysql

1、下载Linux 安装包

官网下载mysql的安装包

image-20210901003856739

2、安装MySQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 卸载 Linux 中预安装的 mysql
rpm -qa | grep -i mysql
rpm -e mysql-libs-5.1.71-1.el6.x86_64 --nodeps

-- 上传 mysql 的安装包
alt + p -- 切换到ftp模式
put E:/test/MySQL-5.6.22-1.el6.i686.rpm-bundle.tar

-- 解压 mysql 的安装包
mkdir mysql
tar -xvf MySQL-5.6.22-1.el6.i686.rpm-bundle.tar -C /root/mysql

-- 安装依赖包
yum -y install libaio.so.1 libgcc_s.so.1 libstdc++.so.6 libncurses.so.5 --setopt=protected_multilib=false
yum update libstdc++-4.4.7-4.el6.x86_64

-- 安装 mysql-client
rpm -ivh MySQL-client-5.6.22-1.el6.i686.rpm

-- 安装 mysql-server
rpm -ivh MySQL-server-5.6.22-1.el6.i686.rpm

3、启动 MySQL 服务

1
2
3
4
5
6
7
8
9
10
11
-- 开启mysql服务
service mysql start

-- 停止mysql服务
service mysql stop

-- 查看mysql的状态
service mysql status

-- 重启mysql
service mysql restart

4、登录MySQL

1
2
3
4
5
6
7
8
9
10
11
12
13
-- mysql 安装完成之后, 会自动生成一个随机的密码, 并且保存在一个密码文件中 : /root/.mysql_secret
cd /root/.mysql_secret
cat

-- 登陆mysql
mysql -u root -p

-- 登录之后, 修改密码
set password = password('your password');

-- 授权远程访问
grant all privileges on *.* to 'root' @'%' identified by 'your password';
flush privileges;

5、Mysql 的用户与权限管理

1、MySQL的用户管理
1、创建用户
1
create user zhang3 identified by '123123';

表示创建名称为zhang3的用户,密码设为123123;

2、查看用户
1
2
3
select host,user,password,select_priv,insert_priv,drop_priv from mysql.user;

select * from user\G;

将 user 中的数据以行的形式显示出来(针对列很长的表可以采用这个方法 )

img

  • host:表示连接类型
    • % 表示所有远程通过 TCP方式的连接
    • IP 地址 如 (192.168.1.2,127.0.0.1) 通过制定ip地址进行的TCP方式的连接
    • ::1 IPv6的本地ip地址 等同于IPv4的 127.0.0.1
    • localhost 本地方式通过命令行方式的连接 ,比如mysql -u xxx -p 123xxx 方式的连接。
  • user:表示用户名
    • 同一用户通过不同方式链接的权限是不一样的。
  • password:密码
    • 所有密码串通过 password(明文字符串) 生成的密文字符串。加密算法为MYSQLSHA1 ,不可逆 。
    • mysql 5.7 的密码保存到 authentication_string,字段中不再使用password 字段。
  • select_priv , insert_priv等
    • 为该用户所拥有的权限。
3、设置密码

修改当前用户的密码:

1
set password =password('123456')

修改某个用户的密码:

1
2
3
4
update mysql.user set password=password('123456') where user='li4';

-- 所有通过user表的修改,必须用该命令才能生效。
flush privileges;
4、修改用户

修改用户名:

1
2
3
4
update mysql.user set user='li4' where user='wang5';

-- 所有通过user表的修改,必须用该命令才能生效。
flush privileges;

img

5、删除用户
1
drop user li4 ;

img

不要通过delete from user u where user=’li4’ 进行删除,系统会有残留信息保留。

2、权限管理
1、授予权限

授权命令:

1
grant 权限1,权限2,…权限n on 数据库名称.表名称 to 用户名@用户地址 identified by ‘连接口令’;

该权限如果发现没有该用户,则会直接新建一个用户。

比如:

1
2
3
4
5
-- 给li4用户用本地命令行方式下,授予atguigudb这个库下的所有表的插删改查的权限。
grant select,insert,delete,drop on atguigudb.* to li4@localhost ;

-- 授予通过网络方式登录的的joe用户 ,对所有库所有表的全部权限,密码设为123.
grant all privileges on *.* to joe@'%' identified by '123';

就算 all privileges 了所有权限,grant_priv 权限也只有 root 才能拥有。

给 root 赋连接口令 grant all privileges on *.* to root@'%' ;后新建的连接没有密码,需要设置密码才能远程连接。

1
update user set password=password('root') where user='root' and host='%';
2、收回权限
1
2
3
4
5
6
7
8
-- 收回权限命令:
revoke 权限1,权限2,…权限n on 数据库名称.表名称 from 用户名@用户地址 ;

-- 若赋的全库的表就 收回全库全表的所有权限
REVOKE ALL PRIVILEGES ON mysql.* FROM joe@localhost;

-- 收回mysql库下的所有表的插删改查权限
REVOKE select,insert,update,delete ON mysql.* FROM joe@localhost;

对比赋予权限的方法:必须用户重新登录后才能生效

3、查看权限
1
2
3
4
5
6
7
8
9
10
11
-- 查看当前用户权限
show grants;

-- 查看某用户的全局权限
select * from user ;

-- 查看某用户的某库的权限
select * from db;

-- 查看某用户的某个表的权限
select * from tables_priv;
3、通过工具远程访问
  1. 先 ping 一下数据库服务器的ip 地址确认网络畅通。

  2. 关闭数据库服务的防火墙

    1
    service iptables stop
  3. 确认Mysql中已经有可以通过远程登录的账户

    1
    select * from mysql.user where user='li4' and host='%';

    如果没有用户,先执行如下命令:

    1
    grant all privileges on *.* to li4@'%' identified by '123123';
  4. 测试连接:

    img

6、修改字符集问题

1、查看字符集
1
2
show variables like 'character%'; 
show variables like '%char%';

默认的是客户端和服务器都用了latin1,所以会乱码。

2、修改my.cnf

在/usr/share/mysql/ 中找到my.cnf的配置文件,

拷贝其中的my-huge.cnf 到 /etc/ 并命名为my.cnf

mysql 优先选中 /etc/ 下的配置文件

然后修改my.cnf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[client]

default-character-set=utf8

[mysqld]

character_set_server=utf8

character_set_client=utf8

collation-server=utf8_general_ci

[mysql]

default-character-set=utf8

img

img

3、重新启动mysql

但是原库的设定不会发生变化,参数修改之对新建的数据库生效

4、已生成的库表字符集如何变更
1
2
3
4
5
-- 修改数据库的字符集
alter database mytest character set 'utf8';

-- 修改数据表的字符集
alter table user convert to character set 'utf8';

但是原有的数据如果是用非’utf8’编码的话,数据本身不会发生改变。

7、Mysql的一些杂项配置

1、大小写问题
1
SHOW VARIABLES LIKE '%lower_case_table_names%' 

img

windows系统默认大小写不敏感,但是linux系统是大小写敏感的

  • 默认为0,大小写敏感。
  • 设置1,大小写不敏感。创建的表,数据库都是以小写形式存放在磁盘上,对于sql语句都是转换为小写对表和DB进行查找。
  • 设置2,创建的表和DB依据语句上格式存放,凡是查找都是转换为小写进行。

设置变量常采用 setlower_case_table_names = 1; 的方式,但此变量是只读权限,所以需要在配置文件中改。

当想设置为大小写不敏感时,要在my.cnf这个配置文件 [mysqld] 中加入 lower_case_table_names = 1 ,然后重启服务器。

但是要在重启数据库实例之前就需要将原来的数据库和表转换为小写,否则更改后将找不到数据库名。

在进行数据库参数设置之前,需要掌握这个参数带来的影响,切不可盲目设置。

2、(生产环境)sql_mode

MySQL的sql_mode合理设置

sql_mode是个很容易被忽视的变量,默认值是空值,在这种设置下是可以允许一些非法操作的,比如允许一些非法数据的插入。在生产环境必须将这个值设置为严格模式,所以开发、测试环境的数据库也必须要设置,这样在开发测试阶段就可以发现问题。

img

使用 set sql_mode=ONLY_FULL_GROUP_BY; 的方式设置会将之前的设置覆盖掉ONLY_FULL_GROUP_BY; 的方式设置会将之前的设置覆盖掉

同时设置多个限制:

1
set sql_mode='ONLY_FULL_GROUP_BY,NO_AUTO_VALUE_ON_ZERO';

sql_mode常用值如下:

  • ONLY_FULL_GROUP_BY:
    • 对于GROUP BY聚合操作,如果在SELECT中的列,没有在GROUP BY中出现,那么这个SQL是不合法的,因为列不在GROUP BY从句中
  • NO_AUTO_VALUE_ON_ZERO:
    • 该值影响自增长列的插入。默认设置下,插入0或NULL代表生成下一个自增长值。如果用户希望插入的值为0,而该列又是自增长的,那么这个选项就有用了。
  • STRICT_TRANS_TABLES:
    • 在该模式下,如果一个值不能插入到一个事务表中,则中断当前的操作,对非事务表不做限制
  • NO_ZERO_IN_DATE:
    • 在严格模式下,不允许日期和月份为零
  • NO_ZERO_DATE:
    • 设置该值,mysql数据库不允许插入零日期,插入零日期会抛出错误而不是警告。
  • ERROR_FOR_DIVISION_BY_ZERO:
    • 在INSERT或UPDATE过程中,如果数据被零除,则产生错误而非警告。如 果未给出该模式,那么数据被零除时MySQL返回NULL
  • NO_AUTO_CREATE_USER:
    • 禁止GRANT创建密码为空的用户
  • NO_ENGINE_SUBSTITUTION:
    • 如果需要的存储引擎被禁用或未编译,那么抛出错误。不设置此值时,用默认的存储引擎替代,并抛出一个异常
  • PIPES_AS_CONCAT:
    • 将”||”视为字符串的连接操作符而非或运算符,这和Oracle数据库是一样的,也和字符串的拼接函数Concat相类似
  • ANSI_QUOTES:
    • 启用ANSI_QUOTES后,不能用双引号来引用字符串,因为它被解释为识别符
  • ORACLE:
    • 设置等同:PIPES_AS_CONCAT, ANSI_QUOTES, IGNORE_SPACE, NO_KEY_OPTIONS, NO_TABLE_OPTIONS, NO_FIELD_OPTIONS, NO_AUTO_CREATE_USER.
3、查看当前系统的性能状态

服务器硬件的性能瓶颈:topfreeiostatvmstat来查看系统的性能状态


相关资料

InnoDB到底支不支持哈希索引,为啥不同的人说的不一样?

黑马程序员MySQL全套教程,超详细的MySQL数据库优化,MySQL面试热点必考

聚簇索引与非聚簇索引(也叫二级索引)–最清楚的一篇讲解

一文搞懂数据库隔离级别及解决方案

京东面试官问我:“聊聊MySql事务,MVCC?”

深入了解mysql–gap locks,Next-Key Locks

Mysql的行锁、表锁、间隙锁、意向锁

[TOC]

1、计算机网络概念

1、什么是计算机网络?

计算机网络是指将==地理位置不同==的具有独立功能的==多台计算机及其外部设备,通过通信线路连接(有线性、无线)起来==,在网络操作系统,网络管理软件及==网络通信协议==的管理和协调下,实现==资源共享==和==信息传递==的计算机系统。

类比:信件

image-20210815223740558

2、网络编程的目的

  • ==传播交流信息==
  • ==数据交换、通信==

3、想要达到这个效果,需要什么

  1. 如何==准确的定位网络上的一台主机== 192.168.1.100: 端口,定位到这个计算机上的某个资源。
  2. 找到了这个主机,如何传输数据呢?

JavaWeb : 网页编程 B/S架构

网络编程: TCP/IP C/S架构

4、网络通信要素

如何实现网络的通信?

  • ==通信双方的地址==:
    • IP:
      • 192.168.1.100
    • 端口号
      • 8080
  • ==规则:网络通信的协议==

5、TCP/IP参考模型

image-20210815222644766

6、OSI七层参考模型 与 TCP/IP参考模型

image-20210815225710618

7、小结

  1. 网络编程中两个主要问题
    • 如何准确定位到网络上的一台或多台主机
    • 找到主机之后如何进行通信
  2. 网络编程中的要素
    • IP 和 端口号
    • 网络通信协议
  3. Java 万物皆对象

2、IP——InetAddress

ip地址:InetAddress

  1. ==唯一定位一台网络上计算机==
  2. ==127.0.0.1: 本机localhost==
  3. ip地址的分类:IPV4/IPv6
    • IPV4 127.0.0.1 4个字节组成,0-255 42亿个 30亿都在北美,亚洲4亿。2011年就用尽
    • IPV6 ;128位。8个无符号整数!
  4. 公网-私网

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package cn.bloghut.lesson01;

import java.net.InetAddress;
import java.net.UnknownHostException;

/**
* @description 测试IP
*/
public class TestInetAddress {
public static void main(String[] args) throws Exception {
//查询本机的ip地址
InetAddress localhost = InetAddress.getByName("localhost");
// InetAddress localhost = InetAddress.getByName("127.0.0.1");
// InetAddress localhost = InetAddress.getLocalHost();
System.out.println(localhost);

InetAddress localHost = InetAddress.getLocalHost();
System.out.println(localHost);
System.out.println("=================");
//查询网站ip地址
InetAddress name = InetAddress.getByName("www.baidu.com");
System.out.println(name);
System.out.println("=================");
//常用方法
//System.out.println(name.getAddress());
System.out.println(name.getHostAddress());//获取主机ip地址
System.out.println(name.getHostName());// 获取域名
System.out.println(name.getCanonicalHostName());//获取规范的主机ip地址
}
}

3、端口——InetSocketAddress

==端口表示计算机上的一个程序的进程。==

  1.  一栋楼表示一个ip ,这栋楼里面的 门牌号 就是端口号。
  2. ==不同的进程有不同的端口号==!用来区分软件的。
  3. 端口被规定为:0-65535
  4. TCP ,UDP: 每个都有 0-65535 * 2 ,单个协议下,端口号不能冲突。
  5. 端口分类
    • 公有端口:0-1023
      • HTTP : 80
      • HTTPS :443
      • FTP : 21
      • Telnet: 23
    • 程序注册端口:1024-49151,分配给用户或者程序
      • Tomcat:8080
      • Mysql:3306
      • Oracle:1521
    • 动态、私有:49152-65535
1
2
3
4
netstat -ano #查看所有端口
netstat -ano | findstr "5900" #查看指定的端口
tasklist | findstr "8696" #查看指定端口的进程
Ctrl + Shift + ESC #打开任务管理器

相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package cn.bloghut.lesson01;

import java.net.InetSocketAddress;

/**
* @description TODO
*/
public class TesyInetSocketAddress {
public static void main(String[] args) {

InetSocketAddress socketAddress = new InetSocketAddress("127.0.0.1", 8080);
InetSocketAddress socketAddress1 = new InetSocketAddress("localhost", 8080);

// /127.0.0.1:8080
System.out.println(socketAddress);
// localhost/127.0.0.1:8080
System.out.println(socketAddress1);
System.out.println("====================");

System.out.println(socketAddress.getAddress());//ip地址
System.out.println(socketAddress.getHostName());//主机名称
System.out.println(socketAddress.getHostString());
System.out.println(socketAddress.getPort());//端口

}
}

4、通信协议

协议:约定,就好比我们现在说的是普通话。

网络通信协议:

  1. 速率
  2. 传输码率
  3. 代码结构 
  4. 传输控制

问题:非常的复杂

TCP/IP协议簇:实际上是一组协议

重要:

  • TCP:用户传输协议
  • UDP:用户数据报协议

出名的协议:

  1. TCP
  2. IP

TCP和UDP 对比:

  • TCP:打电话
    • 连接: 稳定
      • 三次握手
        • A:你愁啥?
        • B:瞅你咋地?
        • A:干一次!
      • 四次挥手
        • A:我要断开了 (我要走了)
        • B:我知道你要断开了(你真的要走了吗?)
        • B:你真的断开了吗?(你真的真的要走了吗?)
        • A:我真的断开了 (我真的要走了)

客户端,服务端

传输完成,释放连接、效率低

image-20210816004713109

1、三次握手

  1. 第一次握手:建立连接时,客户端发送syn包(syn=j)到服务器,并进入SYN_SENT状态,等待服务器确认;SYN:同步序列编号(Synchronize Sequence Numbers)。
  2. 第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态;
  3. 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED(TCP连接成功)状态,完成三次握手。

2、四次挥手

image-20210816004806220

UDP:发短信

  1. 不连接,不稳定
  2. 客户端、服务端:没有明确的界限
  3. 不管有没有准备好,都可以发给你…
  4. 导弹
  5. DDOS:洪水攻击!(饱和攻击)

3、客户端

  1. 建立连接
  2. 发送消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package cn.bloghut.lesson02;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.Socket;

/**
* @description 客户端
*/
public class TcpClientDemo1 {

public static void main(String[] args) throws Exception{
Socket socket = null;
OutputStream os = null;
//要知道服务器地址
try {
InetAddress serverIp = InetAddress.getByName("localhost");
int port = 9999;
//2.创建连接
socket = new Socket(serverIp,port);
//3.发生消息 IO流
os = socket.getOutputStream();
os.write("你好,世界".getBytes());
} catch (IOException e) {
e.printStackTrace();
}finally {
if (os != null) {
os.close();
}
if (socket != null) {
socket.close();
}
}
}
}

4、服务器

  1. 建立服务连接的端口 ServerSocket
  2. 等待用户的连接 accept
  3. 接收用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package cn.bloghut.lesson02;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @description 服务器端
*/
public class TcpServerDemo01 {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = null;
Socket accept = null;
InputStream is = null;
ByteArrayOutputStream baos = null;
try {
//1. 我得有一个地址
serverSocket = new ServerSocket(9999);
//2.等待客户端连接过来
accept = serverSocket.accept();
//3.读取客户端消息
is = accept.getInputStream();

/*byte[] buf = new byte[1024];
int len;
while ((len = is.read(buf)) != -1 ){
String s = new String(buf, 0, len);
System.out.println(s);
}*/

//管道流
baos = new ByteArrayOutputStream();
byte[] buff = new byte[1024];
int len = -1;

while ((len = is.read(buff)) != -1) {
baos.write(buff, 0, len);
}
System.out.println(baos.toString());

} catch (IOException e) {
e.printStackTrace();
} finally {
if (baos != null) {
baos.close();
}
if (is != null) {
is.close();
}
if (accept != null) {
accept.close();
}
if (serverSocket != null) {
serverSocket.close();
}
}
}
}

5、TCP实现文件上传

1、客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package cn.bloghut.lesson02;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @description 服务端
*/
public class TcpServerDemo2 {
public static void main(String[] args) throws Exception{
//1.创建服务
ServerSocket serverSocket = new ServerSocket(9999);
//2.监听客户端连接
Socket accept = serverSocket.accept();
//3.获取输入流
InputStream is = accept.getInputStream();

//4.文件输出
FileOutputStream fos = new FileOutputStream(new File("receive.jpg"));//接收文件就要用文件的管道流
byte[] buff = new byte[1024];
int len;
while ((len = is.read(buff)) != -1){
fos.write(buff,0,len);
}

//通过客户端我接收完毕了
OutputStream os = accept.getOutputStream();
os.write("我接收完毕了,你可以断开了".getBytes());

fos.close();
is.close();
accept.close();
serverSocket.close();
}
}

2、服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package cn.bloghut.lesson02;

import java.io.*;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;

/**
* @description 客户端
*/
public class TcpClientDemo2 {

public static void main(String[] args) throws Exception {
//1.建立连接
Socket socket = new Socket(InetAddress.getByName("127.0.0.1"), 9999);
//2.创建一个输出流
OutputStream os = socket.getOutputStream();
//3.读取文件
FileInputStream is = new FileInputStream(new File("1.jpg"));
byte[] buff = new byte[1024];
int len;
//4.写出文件
while ((len = is.read(buff)) != -1) {
os.write(buff, 0, len);
}

//通知服务器,我已经结束了
socket.shutdownOutput();//我已经传输完了的意思

//确定服务器接收完毕,才能够断开连接
InputStream inputStream = socket.getInputStream();//接收字符、就用字节的管道流
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buff2 = new byte[1024];
int len2;
while ((len2 = inputStream.read(buff)) != -1) {
bos.write(buff2, 0, len2);
}
System.out.println(bos.toString());

//5.释放资源
bos.close();
inputStream.close();
is.close();
os.close();
socket.close();
}
}

6、UDP:发短信,需要IP地址

1、发送端:发送消息——DatagramPacket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package cn.bloghut.lesson3;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

/**
* @description 不需要连接服务器
*/
public class UdpClientDemo1 {
public static void main(String[] args) throws Exception{

//1.建立一个Socket
DatagramSocket socket = new DatagramSocket();
//2.建个包
String msg = "你好啊,服务器";
//3.发送给谁
InetAddress address = InetAddress.getByName("localhost");
int port = 9090;

DatagramPacket packet = new DatagramPacket(msg.getBytes(), 0, msg.getBytes().length, address, port);
//4.发送包
socket.send(packet);
}
}

2、接收端:接收消息——DatagramSocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package cn.bloghut.lesson3;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.ServerSocket;

/**
* @description TODO
*/
public class UdpServerDemo1 {
public static void main(String[] args) throws Exception{
//开放端口
DatagramSocket socket = new DatagramSocket(9090);
//接收数据包
byte[] buff =new byte[1024];
DatagramPacket packet = new DatagramPacket(buff, 0, buff.length);

socket.receive(packet);//阻塞接收

System.out.println(packet.getAddress());
System.out.println(new String(packet.getData(),0,packet.getData().length));

socket.close();
}
}

7、UDP 实现聊天实现

1、发送方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package cn.bloghut.chat;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;

/**
* @description 接收端
*/
public class UdpSenderDemo01 {
public static void main(String[] args) throws Exception {
//获取连接
DatagramSocket socket = new DatagramSocket(8080);
while (true) {
//准备数据
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String data = reader.readLine();
byte[] datas = data.getBytes();
DatagramPacket packet = new DatagramPacket
(datas, 0,datas.length, new InetSocketAddress("localhost", 6666));
//发送数据
socket.send(packet);
if (data.equals("bye")) {
break;
}
}
socket.close();
}
}

2、接收端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package cn.bloghut.chat;

import java.net.DatagramPacket;
import java.net.DatagramSocket;

/**
* @description 接收端
*/
public class UdpReceiveDemo01 {

public static void main(String[] args) throws Exception {
DatagramSocket socket = new DatagramSocket(6666);
while (true) {
//准备接收包裹
byte[] container = new byte[1024];
DatagramPacket packet = new DatagramPacket(container, 0, container.length);
socket.receive(packet);//阻塞式接收包裹


byte[] data = packet.getData();
String receiveData = new String(data, 0, data.length);

System.out.println(receiveData);

//断开连接 bye
if (receiveData.equals("bye")){
break;
}
}
socket.close();
}
}

5、参考文档

B站【狂神说Java笔记】-网络编程

[TOC]

Netty

1、Netty的介绍以及应用场景

1、Netty的基本介绍

  1. Netty 是由 JBOSS 提供的一个 Java 开源框架,现为 Github上的独立项目。

  2. Netty 是一个==异步的==、==基于事件驱动==的==网络应用框架==,用以快速开发高性能、高可靠性的网络 IO 程序

    image-20210816030806875

  3. Netty主要针对在==TCP协议==下,==面向Clients端==的高并发应用,或者==Peer-to-Peer场景==下的大量数据持续传输的应用。

  4. Netty本质是一个==NIO框架==,适用于服务器通讯相关的多种应用场景

  5. 要透彻理解Netty , 需要先学习 NIO , 这样我们才能阅读 Netty 的源码。

    image-20210816030833354

2、Netty的应用场景

1、互联网行业

  1. 互联网行业:在分布式系统中,各个节点之间需要远程服务调用,高性能的 RPC 框架必不可少,Netty 作为异步高性能的通信框架,往往作为基础通信组件被这些 RPC 框架使用

  2. 典型的应用有:阿里分布式服务框架 Dubbo 的 RPC 框架使用 Dubbo 协议进行节点间通信,Dubbo 协议默认使用 Netty 作为基础通信组件,用于实现各进程节点之间的内部通信

    image-20210816031145708

2、游戏行业

  1. 无论是手游服务端还是大型的网络游戏,Java 语言得到了越来越广泛的应用
  2. Netty 作为高性能的基础通信组件,提供了 TCP/UDP 和 HTTP 协议栈,方便定制和开发私有协议栈,账号登录服务器
  3. 地图服务器之间可以方便的通过 Netty 进行高性能的通信

3、大数据领域

  1. 经典的 Hadoop 的高性能通信和序列化组件 Avro(实现数据文件共享) 的 RPC 框架,默认采用 Netty 进行跨界点通信

  2. 它的 Netty Service 基于 Netty 框架二次封装实现。

    image-20210816031437724

4、其它开源项目使用到Netty

网址: https://netty.io/wiki/related-projects.html

image-20210816031742962

3、Netty的学习参考资料

  • 《Netty IN Action》
    • image-20210816031941432
  • Netty权威指南
    • image-20210816032001424

2、Java BIO编程

1、I/O模型

1、I/O模型的基本说明

  • I/O 模型简单的理解:就是用什么样的通道进行数据的发送和接收,很大程度上决定了程序通信的性能

  • Java共支持3种网络编程模型/IO模式:BIONIOAIO

    • Java BIO: 同步并阻塞(传统阻塞型),服务器实现模式为==一个连接一个线程==,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销

      image-20210816032943453

    • Java NIO同步非阻塞,服务器实现模式为==一个线程处理多个请求(连接)==,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理

      image-20210816033058098

    • Java AIO(NIO.2)异步非阻塞,AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用

2、BIO、NIO、AIO适用场景分析

  • BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序简单易理解。
  • NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4开始支持。
  • AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。

2、Java BIO 基本介绍

  1. Java BIO 就是传统的java io编程,其相关的类和接口在 java.io
  2. BIO(blocking I/O)**: **同步阻塞,服务器实现模式为==一个连接一个线程==,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)。 【后有应用实例
  3. BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,程序简单易理解

3、Java BIO 工作机制

1、工作原理图

image-20210816033912512

2、BIO编程简单流程

  1. 服务器端启动一个ServerSocket
  2. 客户端启动Socket对服务器进行通信,默认情况下服务器端需要对每个客户建立一个线程与之通讯
  3. 客户端发出请求后,先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝
  4. 如果有响应,客户端线程会等待请求结束后,在继续执行

4、Java BIO 应用实例

实例说明:

  1. 使用BIO模型编写一个服务器端,监听6666端口,当有客户端连接时,就启动一个线程与之通讯。
  2. 要求使用线程池机制改善,可以连接多个客户端;
  3. 服务器端可以接收客户端发送的数据(telnet 方式即可)。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.awo.bio;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BIOServer {
public static void main(String[] args) throws IOException {

//线程池机制

//思路
//1. 创建一个线程池
//2. 如果有客户端连接,就创建一个线程,与之通讯(单独写一个方法)

//1. 创建一个线程池
ExecutorService pool = Executors.newCachedThreadPool();

//创建ServerSocket
ServerSocket serverSocket = new ServerSocket(6666);

System.out.println("服务器启动了");

while (true) {

//监听,等待客户端连接
System.out.println("等待连接....");
final Socket socket = serverSocket.accept();
System.out.println("连接到一个客户端");

//就创建一个线程,与之通讯(单独写一个方法)
pool.execute(() -> {
//可以和客户端通讯
handler(socket);
});
}
}

//编写一个handler方法,和客户端通讯
public static void handler(Socket socket) {
InputStream is = null;
try {
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
byte[] bytes = new byte[1024];
//通过socket 获取输入流
is = socket.getInputStream();

//循环的读取客户端发送的数据
while (true) {
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
System.out.println("read....");
int read;
if ((read = is.read(bytes)) != -1) {
//输出客户端发送的数据
System.out.println(new String(bytes, 0, read));
} else {
break;
}

}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (is == null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println("关闭和client的连接");
if (socket == null) {
try {
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}

5、Java BIO 问题分析

  1. 每个请求都需要创建独立的线程,与对应的客户端进行数据 Read,业务处理,数据 Write 。
  2. 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
  3. 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费

3、Java NIO编程

1、Java NIO 基本介绍

  1. Java NIO 全称 java non-blocking IO,是指 JDK 提供的新 API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO),是同步非阻塞

  2. NIO 相关类都被放在 java.nio 包及子包下,并且对原 java.io 包中的很多类进行改写。

    image-20210816204510649

  3. NIO 有三大核心部分:Channel(通道)Buffer(缓冲区)Selector(选择器)

  4. NIO是 ==面向缓冲区== ,或者==面向 块 编程==的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络

  5. Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。【后面有案例说明

  6. 通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有10000个请求过来,根据实际情况,可以分配50或者100个线程来处理。不像之前的阻塞IO那样,非得分配10000个。

  7. HTTP2.0使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求的数量比HTTP1.1大了好几个数量级。

2、NIO 和 BIO的比较

  1. BIO 以==流==的方式处理数据,而 NIO 以==块==的方式处理数据,块 I/O 的效率比流 I/O 高很多
  2. BIO 是==阻塞==的,NIO 则是==非阻塞==的
  3. BIO基于==字节流==和==字符流==进行操作,而 NIO 基于 ==Channel(通道)==和 ==Buffer(缓冲区)==进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于==监听多个通道的事件==(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道

3、NIO 三大核心原理示意图

一张图描述NIO 的 SelectorChannelBuffer 的关系:

image-20210816205036305

SelectorChannelBuffer 的关系图(简单版)关系图的说明:

  1. 每个channel 都会对应一个Buffer
  2. Selector 对应一个线程, 一个线程对应多个channel(连接)
  3. 该图反应了有三个channel 注册到 该selector //程序
  4. 程序切换到哪个channel 是有事件决定的,Event 就是一个重要的概念
  5. Selector 会根据不同的事件,在各个通道上切换
  6. Buffer 就是一个内存块 ,底层是有一个数组
  7. 数据的读取写入是通过Buffer,这个和BIO有着本质的不同:BIO 中要么是输入流,要么是输出流,不能双向,但是NIO的Buffer 是可以读也可以写,需要 flip 方法切换
  8. channel 是双向的,可以返回底层操作系统的情况,比如Linux:底层的操作系统通道就是双向的。

4、缓冲区(Buffer)

1、基本介绍

缓冲区(Buffer):缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组)**,该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况**。Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer,如图: 【后面举例说明】

image-20210816220148414

2、Buffer 类及其子类

1、Buffer类继承关系

在 NIO 中,Buffer 是一个顶层父类,它是一个抽象类,类的层级关系图

  • 常用Buffer子类一览
    • ByteBuffer:存储字节数据到缓冲区
    • ShortBuffer:存储字符串数据到缓冲区
    • CharBuffer:存储字符数据到缓冲区
    • IntBuffer:存储整数数据到缓冲区
    • LongBuffer:存储长整型数据到缓冲区
    • DoubleBuffer:存储小数到缓冲区
    • FloatBuffer:存储小数到缓冲区
    • image-20210816222453542
    • image-20210816222527728
  • 每一个Buffer的实现类都有一个属性:hb(不同实现类该属性的类型不同,但都是一个数组),数据实际上就是存放在hb数组里面的
2、Buffer的四个主要属性

Buffer类定义了所有的缓冲区都具有的四个属性来提供关于其所包含的数据元素的信息:

  • Capacity:容量,即可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变
  • Limit:表示缓冲区的当前终点,不能对缓冲区超过极限的位置进行读写操作(左闭右开)。且极限是可以修改的
  • Position:位置,下一个要被读或写的元素的索引,每次读写缓冲区数据时都会改变改值,为下次读写作准备
  • Mark:标记(很少主动修改)
1
2
3
4
5
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;

其中最重要的flip()方法:用来切换Buffer的读写(其中对于limit是一个“左闭右开区间”)

1
2
3
4
5
6
7
8
public final Buffer flip() {
// 将当前所在位置设置成缓存区的终点
limit = position;
// 将当前位置归0
position = 0;
mark = -1;
return this;
}

左闭右开:

image-20210816233421568

3、Buffer类相关方法一览
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public abstract class Buffer {
//JDK1.4时,引入的api
public final int capacity();//返回此缓冲区的容量(重要)
public final int position();//返回此缓冲区的位置(重要)
public final Buffer position (int newPosition);//设置此缓冲区的位置(重要)
public final int limit();//返回此缓冲区的限制(重要)
public final Buffer limit (int newLimit);//设置此缓冲区的限制(重要)

public final Buffer mark();//在此缓冲区的位置设置标记
public final Buffer reset();//将此缓冲区的位置重置为以前标记的位置

public final Buffer clear();//清除此缓冲区, 即将各个标记恢复到初始状态,但是数据并没有真正擦除, 后面操作会覆盖(重要)
public final Buffer flip();//反转此缓冲区(重要)

public final Buffer rewind();//重绕此缓冲区
public final int remaining();//返回当前位置与限制之间的元素数

public final boolean hasRemaining();//告知在当前位置和限制之间是否有元素(重要)
public abstract boolean isReadOnly();//告知此缓冲区是否为只读缓冲区(重要)

//JDK1.6时引入的api
public abstract boolean hasArray();//告知此缓冲区是否具有可访问的底层实现数组(重要)
public abstract Object array();//返回此缓冲区的底层实现数组(重要)

public abstract int arrayOffset();//返回此缓冲区的底层实现数组中第一个缓冲区元素的偏移量
public abstract boolean isDirect();//告知此缓冲区是否为直接缓冲区
}

注:什么是直接缓冲区?

  • 直接缓冲区指的是操作系统的缓冲区
  • 而平常的缓冲区通常都是JVM分配的缓冲区
4、ByteBuffer(最常用)

从前面可以看出对于 Java 中的基本数据类型(boolean除外),都有一个 Buffer 类型与之相对应,最常用的自然是ByteBuffer 类(二进制数据),该类的主要方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class ByteBuffer {
//缓冲区创建相关api
public static ByteBuffer allocateDirect(int capacity);//创建直接缓冲区(重要)
public static ByteBuffer allocate(int capacity);//设置缓冲区的初始容量(重要)

public static ByteBuffer wrap(byte[] array);//把一个数组放到缓冲区中使用
//构造初始化位置offset和上界length的缓冲区
public static ByteBuffer wrap(byte[] array,int offset, int length);

//缓存区存取相关API
public abstract byte get( );//从当前位置position上get,get之后,position会自动+1(重要)
public abstract byte get (int index);//从绝对位置get(重要)
//从当前位置上添加,put之后,position会自动+1(重要)
public abstract ByteBuffer put (byte b);
public abstract ByteBuffer put (int index, byte b);//从绝对位置上put(重要)
}

image-20210816234508355

5、通道(Channel)

1、基本介绍

  1. NIO的通道类似于流,但有些区别如下:

    • 通道可以同时进行读写,而流只能读或者只能写
    • 通道可以实现异步读写数据
    • 通道可以从缓冲读数据,也可以写数据到缓冲:
      • image-20210816235045647
  2. BIO 中的 stream 是单向的,例如 FileInputStream 对象只能进行读取数据的操作,而 NIO 中的通道(Channel)是双向的,可以读操作,也可以写操作。

  3. Channel在NIO中是一个接口:

    • public interface Channel extends Closeable{} 
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32

      4. 常用的 Channel 类有:

      - `FileChannel`:用于文件的数据读写
      - `DatagramChannel`:用于 UDP 的数据读写
      - `ServerSocketChannel`:用于 TCP 的数据读写
      - ServerSocketChanne 类似 ServerSocket
      - `SocketChannel`:用于 TCP 的数据读写
      - SocketChannel 类似 Socket

      ![image-20210817001954099](Netty/image-20210817001954099.png)

      ![image-20210817002125912](Netty/image-20210817002125912.png)



      #### 2、FileChannel类

      FileChannel主要用来对本地文件进行 IO 操作,常见的方法有:

      ```java
      // 从通道读取数据并放到缓冲区中
      public int read(ByteBuffer dst);

      // 把缓冲区的数据写到通道中
      public int write(ByteBuffer src);

      // 从目标通道中复制数据到当前通道(可以用来做文件的拷贝,速度很快)
      public long transferFrom(ReadableByteChannel src, long position, long count);

      // 把数据从当前通道复制给目标通道(底层实现了零拷贝,速度很快)
      public long transferTo(long position, long count, WritableByteChannel target);

3、应用实例

1、应用实例1——本地文件写数据

实例要求:

  1. 使用前面的ByteBuffer(缓冲) 和 FileChannel(通道), 将 “hello,world” 写入到file01.txt 中
  2. 文件不存在就创建

分析:

image-20210817012929177

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.awo.nio;

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel01 {

public static void main(String[] args) throws IOException {
String str = "hello,world";
//创建一个输出流->channel

FileOutputStream fos = new FileOutputStream("D:\\编程\\netty\\src\\file01.txt");

//通过 fileOutputStream 获取 对应的 FileChannel
//这个 fileChannel 真实 类型是 FileChannelImpl
FileChannel fileChannel = fos.getChannel();

//创建一个缓冲区 ByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

//将 str 放入 byteBuffer
byteBuffer.put(str.getBytes());

//对byteBuffer 进行flip
byteBuffer.flip();

//将byteBuffer 数据写入到 fileChannel
fileChannel.write(byteBuffer);
fos.close();
}
}
2、应用实例2——本地文件读数据

实例要求:

  1. 使用前面的ByteBuffer(缓冲) 和 FileChannel(通道), 将 file01.txt 中的数据读入到程序,并显示在控制台屏幕
  2. 假定文件已经存在

分析:

image-20210817040423887

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.awo.nio;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel02 {
public static void main(String[] args) throws IOException {

//创建文件的输入流
File file = new File("D:\\编程\\netty\\src\\file01.txt");
FileInputStream fis = new FileInputStream(file);

//通过fileInputStream 获取对应的FileChannel -> 实际类型 FileChannelImpl
FileChannel channel = fis.getChannel();

//创建缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

//将 通道的数据读入到Buffer
channel.read(byteBuffer);

//将byteBuffer 的 字节数据 转成String
System.out.println(new String(byteBuffer.array()));
fis.close();
}
}
3、应用实例3——使用一个Buffer完成文件读取

实例要求:

  1. 使用 FileChannel(通道) 和 方法 read , write,完成文件的拷贝
  2. 拷贝一个文本文件 1.txt , 放在项目下即可

分析:

image-20210817040359556

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.awo.nio;

import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel03 {
public static void main(String[] args) throws IOException {

File file = new File("1.txt");
FileInputStream fis = new FileInputStream(file);
FileChannel readChannel = fis.getChannel();

FileOutputStream fos = new FileOutputStream("2.txt");
FileChannel writeChannel = fos.getChannel();

ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

//循环读取
while (true) {

//这里有一个重要的操作,一定不要忘了
//清空buffer,其实就是复位一下属性值
byteBuffer.clear();
int read = readChannel.read(byteBuffer);
//表示读完
if (read == -1) {
break;
}
//将buffer 中的数据写入到 fileChannel02 -- 2.txt
byteBuffer.flip();
writeChannel.write(byteBuffer);
}

//关闭相关的流
fis.close();
fos.close();
}
}

clear()的相关代码:

1
2
3
4
5
6
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
4、应用实例4——拷贝文件transferFrom 方法

实例要求:

  1. 使用 FileChannel(通道) 和 方法 transferFrom ,完成文件的拷贝
  2. 拷贝一张图片

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.awo.nio;

import java.io.*;
import java.nio.channels.FileChannel;

public class NIOFileChannel04 {
public static void main(String[] args) throws IOException {
//创建相关流
File file = new File("Koala.jpg");
FileInputStream fis = new FileInputStream(file);
FileOutputStream fos = new FileOutputStream("Koala01.jpg");

//获取各个流对应的filechannel
FileChannel sourceCh = fis.getChannel();
FileChannel destCh = fos.getChannel();

//使用transferForm完成拷贝
destCh.transferFrom(sourceCh, 0, sourceCh.size());
//关闭相关通道和流
destCh.close();
sourceCh.close();
fos.close();
fis.close();
}
}

4、关于Buffer 和 Channel的注意事项和细节

  1. ByteBuffer 支持类型化的put 和 get, put 放入的是什么数据类型,get就应该使用相应的数据类型来取出,否则可能有 BufferUnderflowException 异常。
  2. 可以将一个普通Buffer 转成只读Buffer:使用buffer.asReadOnlyBuffer();方法将一个普通buffer转换成只读Buffer
    • 如果在只读Buffer当中添加数据,会抛出一个ReadOnlyBufferException异常
  3. NIO 还提供了 MappedByteBuffer, 可以让文件直接在内存(堆外的内存)中进行修改, 而如何同步到文件由NIO 来完成。调用channel.map(FileChannel.MapMode.READ_WRITE, 0, 6);方法生成一个MappedByteBuffer对象
    • 注意:map的几个参数
      • MapMode mode:映射的模式——与上面创建流的模式对应
      • long position:从哪里开修改,即修改的开始位置
      • long size:修改的大小,如果修改的地方超过设置的修改大小,会抛出一个IndexOutOfBoundsException异常
  4. 前面我们讲的读写操作,都是通过一个Buffer 完成的,NIO 还支持 通过多个Buffer (即 Buffer 数组) 完成读写操作,即 ScatteringGathering
    • Scattering:将数据写入到buffer时,可以采用buffer数组,依次写入 [分散读取]
    • Gathering: 从buffer读取数据时,可以采用buffer数组,依次读取 [聚集写入]
1、关于MappedByteBuffer的相关示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.awo.nio;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

/**
* 说明:MappedByteBuffer 可让文件直接在内存(堆外内存)修改, 操作系统不需要拷贝一次
*/
public class MappedByteBufferTest {
public static void main(String[] args) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile("1.txt", "rw");
//获取对应的通道
FileChannel channel = randomAccessFile.getChannel();

/**
* 参数1: FileChannel.MapMode.READ_WRITE 使用的读写模式
* 参数2: 0 : 可以直接修改的起始位置
* 参数3: 6: 是映射到内存的大小(不是索引位置) ,即将 1.txt 的多少个字节映射到内存
* 可以直接修改的范围就是 0-6
* 实际类型 DirectByteBuffer
*/
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 6);

mappedByteBuffer.put(0, (byte) 'H');
mappedByteBuffer.put(5, (byte) '-');
// 抛出异常:IndexOutOfBoundsException
// mappedByteBuffer.put(6, (byte) '9');

randomAccessFile.close();
System.out.println("修改成功");
}
}
2、关于 Scattering 和 Gathering的相关示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.awo.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Arrays;

/**
* Scattering:将数据写入到buffer时,可以采用buffer数组,依次写入 [分散读取]
* Gathering: 从buffer读取数据时,可以采用buffer数组,依次读取 [聚集写入]
*/
public class ScatteringAndGatheringTest {
public static void main(String[] args) throws IOException {
//使用 ServerSocketChannel 和 SocketChannel 网络
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
InetSocketAddress inetSocketAddress = new InetSocketAddress(7777);

//绑定端口到socket ,并启动
serverSocketChannel.socket().bind(inetSocketAddress);

//创建buffer数组
ByteBuffer[] byteBuffers = new ByteBuffer[2];
byteBuffers[0] = ByteBuffer.allocate(5);
byteBuffers[1] = ByteBuffer.allocate(3);

//等客户端连接(telnet)
SocketChannel socketChannel = serverSocketChannel.accept();
//假定从客户端接收8个字节
int messageLength = 8;

//循环的读取
while (true) {
int byteRead = 0;
while (byteRead < messageLength) {
long read = socketChannel.read(byteBuffers);
//累计读取的字节数
byteRead += read;
System.out.println("byteRead=" + byteRead);
//使用流打印, 看看当前的这个buffer的position 和 limit
Arrays.asList(byteBuffers).stream().map(buffer -> "position=" + buffer.position() + ", limit=" + buffer.limit())
.forEach(System.out::println);
}
//将所有的buffer进行flip
Arrays.asList(byteBuffers).forEach(buffer -> buffer.flip());

//将数据读出显示到客户端
long byteWirte = 0;
while (byteWirte < messageLength) {
long write = socketChannel.write(byteBuffers);
byteWirte += write;
}

//将所有的buffer 进行clear
Arrays.asList(byteBuffers).forEach(buffer -> buffer.clear());
System.out.println("byteRead:=" + byteRead + " byteWrite=" + byteWirte + ", messagelength" + messageLength);
}

}
}

6、Selector(选择器)

1、基本介绍

  1. Java 的 NIO,用非阻塞的 IO 方式。可以用一个线程,处理多个的客户端连接,就会使用到Selector选择器
  2. **Selector 能够检测多个注册的通道上是否有事件发生(注意:多个Channel以事件的方式可以注册到同一个Selector)**,如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。
  3. 只有在 连接/通道 真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程
  4. 避免了多线程之间的上下文切换导致的开销

2、Selector示意图和特点说明

1、Selector示意图

image-20210817171431861

2、特点说明
  1. Netty 的 IO 线程 NioEventLoop 聚合了 Selector(选择器,也叫多路复用器),可以同时并发处理成百上千个客户端连接。
  2. 当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。
  3. 线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。
  4. 由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。
  5. 一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

3、Selector类相关方法

Selector 类是一个抽象类,以下为Selector的相关方法:

image-20210817172751502

常用方法和说明如下:

1
2
3
4
5
6
7
8
9
10
public abstract class Selector implements Closeable { 
//得到一个选择器对象
public static Selector open();

//监控所有注册的通道,当其中有 IO 操作可以进行时,将对应的 SelectionKey 加入到内部集合中并返回,参数用来设置超时时间
public int select(long timeout);

//从内部集合中得到所有的 SelectionKey
public Set<SelectionKey> selectedKeys();
}

4、注意事项

  1. NIO中的 ServerSocketChannel功能类似ServerSocket,SocketChannel功能类似Socket

  2. selector 相关方法说明:

    • selector.select()//阻塞
      
      selector.select(1000);//阻塞1000毫秒,在1000毫秒后返回
      
      selector.wakeup();//唤醒selector
      
      selector.selectNow();//不阻塞,立马返还
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118



      ### 7、NIO 非阻塞 网络编程

      #### 1、NIO 非阻塞 网络编程原理分析图

      NIO 非阻塞 网络编程相关的(`Selector`、`SelectionKey`、`ServerScoketChannel`和`SocketChannel`) 关系梳理图:

      ![image-20210817173445617](Netty/image-20210817173445617.png)

      对上图的说明:

      1. 当客户端连接时,会通过 ServerSocketChannel 得到 SocketChannel
      2. Selector 进行监听 select 方法, 返回有事件发生的通道的个数.
      3. 将 socketChannel 注册到Selector上,register(Selector sel, int ops), 一个selector上可以注册多个SocketChannel
      - 其中register(Selector sel, int ops)方法的两个参数:
      - Selector sel:想要注册到的选择器
      - int ops:SelectionKey与Channel的注册关系
      - `int OP_ACCEPT`:有新的网络连接可以 accept,值为 16
      - `int OP_CONNECT`:代表连接已经建立,值为 8
      - `int OP_READ`:代表读操作,值为 1
      - `int OP_WRITE`:代表写操作,值为 4
      4. 注册后返回一个 SelectionKey,会和该Selector 关联(集合)
      5. 进一步得到各个 SelectionKey (有事件发生)
      6. 在通过 SelectionKey 反向获取 SocketChannel,方法 channel()
      7. 可以通过channel()方法得到的 channel , 完成业务处理



      #### 2、NIO 非阻塞 网络编程快速入门

      案例要求:

      1. 编写一个 NIO 入门案例,实现服务器端和客户端之间的数据简单通讯(非阻塞)
      2. 目的:理解NIO非阻塞网络编程机制

      代码示例:

      - 服务端:

      ```java
      package com.awo.nio;

      import java.io.IOException;
      import java.net.InetSocketAddress;
      import java.nio.ByteBuffer;
      import java.nio.channels.*;
      import java.util.Iterator;
      import java.util.Set;

      public class NIOServer {

      public static void main(String[] args) throws IOException {
      //创建ServerSocketChannel -> ServerSocket
      ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
      //得到一个Selector对象
      Selector selector = Selector.open();
      //绑定一个端口6666, 在服务器端监听
      serverSocketChannel.socket().bind(new InetSocketAddress(6666));
      //设置为非阻塞
      serverSocketChannel.configureBlocking(false);
      //把 serverSocketChannel 注册到 selector 关心 事件为 OP_ACCEPT
      serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

      // 1
      System.out.println("注册后的Selectionkey 数量=" + selector.keys().size());

      //循环等待客户端连接
      while (true) {

      //这里我们等待1秒,如果没有事件发生, 返回
      if (selector.select(1000) == 0) {
      //没有事件发生
      System.out.println("服务器等待了1秒,无连接");
      continue;
      }
      //如果返回的>0, 就获取到相关的 selectionKey集合
      //1.如果返回的>0, 表示已经获取到关注的事件
      //2. selector.selectedKeys() 返回关注事件的集合
      // 通过 selectionKeys 反向获取通道
      Set<SelectionKey> selectionKeys = selector.selectedKeys();
      System.out.println("selectionKeys 数量 = " + selectionKeys.size());
      //遍历 Set<SelectionKey>, 使用迭代器遍历
      Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
      while (keyIterator.hasNext()) {
      //获取到SelectionKey
      SelectionKey selectionKey = keyIterator.next();
      //根据key 对应的通道发生的事件做相应处理
      //如果是 OP_ACCEPT, 有新的客户端连接
      if (selectionKey.isAcceptable()) {
      //该该客户端生成一个 SocketChannel
      SocketChannel socketChannel = serverSocketChannel.accept();
      System.out.println("客户端连接成功 生成了一个 socketChannel " + socketChannel.hashCode());
      //将 SocketChannel 设置为非阻塞
      socketChannel.configureBlocking(false);
      //将socketChannel 注册到selector, 关注事件为 OP_READ, 同时给socketChannel关联一个Buffer
      ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
      socketChannel.register(selector, SelectionKey.OP_READ, byteBuffer);
      //2,3,4..
      System.out.println("客户端连接后 ,注册的selectionkey 数量=" + selector.keys().size());
      }
      //发生 OP_READ
      if (selectionKey.isReadable()) {
      //通过key 反向获取到对应channel
      SocketChannel channel = (SocketChannel) selectionKey.channel();
      //获取到该channel关联的buffer
      ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
      channel.read(buffer);
      System.out.println("form 客户端 " + new String(buffer.array()));
      }

      //手动从集合中移动当前的selectionKey, 防止重复操作
      keyIterator.remove();
      }
      }
      }
      }
  • 客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.awo.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class NIOClient {
public static void main(String[] args) throws IOException {
//得到一个网络通道
SocketChannel socketChannel = SocketChannel.open();
//设置非阻塞
socketChannel.configureBlocking(false);
//提供服务器端的ip 和 端口
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
//连接服务器
if (!socketChannel.connect(inetSocketAddress)) {
while (!socketChannel.finishConnect()) {
System.out.println("因为连接需要时间,客户端不会阻塞,可以做其它工作..");
}
}
//...如果连接成功,就发送数据
String str = "hello, world";
//Wraps a byte array into a buffer
ByteBuffer byteBuffer = ByteBuffer.wrap(str.getBytes());
//发送数据,将 buffer 数据写入 channel
socketChannel.write(byteBuffer);
System.in.read();
}
}

8、SelectionKey

1、SelectionKey和网络通道的注册关系

SelectionKey,表示 Selector 和网络通道的注册关系, 共四种:

  • int OP_ACCEPT:有新的网络连接可以 accept,值为 16
  • int OP_CONNECT:代表连接已经建立,值为 8
  • int OP_READ:代表读操作,值为 1
  • int OP_WRITE:代表写操作,值为 4

相关源代码:

1
2
3
4
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

2、SelectionKey相关方法

image-20210817224717376

其中几个比较常用的方法:

1
2
3
4
5
6
7
8
9
public abstract class SelectionKey {
public abstract Selector selector();//得到与之关联的 Selector 对象
public abstract SelectableChannel channel();//得到与之关联的通道
public final Object attachment();//得到与之关联的共享数据
public abstract SelectionKey interestOps(int ops);//设置或改变监听事件
public final boolean isAcceptable();//是否可以 accept
public final boolean isReadable();//是否可以读
public final boolean isWritable();//是否可以写
}

9、ServerSocketChannel

ServerSocketChannel 在服务器端监听新的客户端 Socket 连接

ServerSocketChannel相关方法如下:

image-20210817225056336

常用方法以及说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class ServerSocketChannel extends AbstractSelectableChannel  implements NetworkChannel{
// 得到一个 ServerSocketChannel 通道(静态方法)
public static ServerSocketChannel open();

// 设置服务器端端口号
public final ServerSocketChannel bind(SocketAddress local);

// 设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
public final SelectableChannel configureBlocking(boolean block);

// 接受一个连接,返回代表这个连接的通道对象
public SocketChannel accept();

// 注册一个选择器并设置监听事件
public final SelectionKey register(Selector sel, int ops);
}

10、SocketChannel

SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 把缓冲区的数据写入通道,或者把通道里的数据读到缓冲区。

相关方法如下:

image-20210817225834704

常用方法以及说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public abstract class SocketChannel extends AbstractSelectableChannel implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel{

//得到一个 SocketChannel 通道
public static SocketChannel open();

//设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
public final SelectableChannel configureBlocking(boolean block);

//连接服务器
public boolean connect(SocketAddress remote);

//如果上面的方法连接失败,接下来就要通过该方法完成连接操作
public boolean finishConnect();

//往通道里写数据
public int write(ByteBuffer src);

//从通道里读数据
public int read(ByteBuffer dst);

//注册一个选择器并设置监听事件,最后一个参数可以设置共享数据
public final SelectionKey register(Selector sel, int ops, Object att);

//关闭通道
public final void close();
}

11、NIO 网络编程应用实例——群聊系统

1、实例要求

  1. 编写一个 NIO 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
  2. 实现多人群聊
  3. 服务器端:可以监测用户上线,离线,并实现消息转发功能
  4. 客户端:通过channel 可以无阻塞发送消息给其它所有用户,同时可以接受其它用户发送的消息(有服务器转发得到)
  5. 目的:进一步理解NIO非阻塞网络编程机制

2、实例需求图

image-20210817230238423

3、分析

  1. 先编写服务器端
    1. 服务器启动并监听 6667
    2. 服务器接收客户端信息,并实现转发 [处理上线和离线]
    3. 其中转发注意需要排除发送消息的客户端
  2. 编写客户端
    1. 连接服务器
    2. 发送消息
    3. 接收服务器消息

4、代码

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
package com.awo.nio.groupchat;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;

public class GroupChatServer {
// 定义属性

private ServerSocketChannel listenChannel;
private Selector selector;
private static final int PORT = 6667;

// 构造器
// 初始化工作
public GroupChatServer() {
try {
// ServerSocketChannel
listenChannel = ServerSocketChannel.open();
// 得到选择器
selector = Selector.open();
// 绑定端口
listenChannel.socket().bind(new InetSocketAddress(PORT));
// 设置非阻塞模式
listenChannel.configureBlocking(false);
// 将该listenChannel 注册到selector
listenChannel.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 监听
*/
public void listen() {
System.out.println("监听线程: " + Thread.currentThread().getName());

try {
// 循环处理
while (true) {
int count = selector.select();
// 有事件处理
if (count > 0) {
// 得到SelectionKeys的迭代器
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
// 取出selectionkey
SelectionKey key = iterator.next();
// 监听到accept
if (key.isAcceptable()) {
// 得到socketChannel
SocketChannel socketChannel = listenChannel.accept();
// 设置非阻塞模式
socketChannel.configureBlocking(false);
// 将该 socketChannel 注册到 selector
socketChannel.register(selector, SelectionKey.OP_READ);
// 提示
System.out.println(socketChannel.getRemoteAddress() + " 上线 ");
}
// 通道发送read事件,即通道是可读的状态
if (key.isReadable()) {
// 调用readData处理读事件
readData(key);
}
// 当前的key 删除,防止重复处理
iterator.remove();
}
} else {
System.out.println("等待....");
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
//发生异常处理....
}
}

/**
* 读取客户端消息
* @param key
*/
private void readData(SelectionKey key) {
// 定义一个SocketChannel
SocketChannel channel = null;

try {
// 取到关联的channel
channel = (SocketChannel) key.channel();
// 创建buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 从buffer读取数据到channel
int count = channel.read(buffer);
// 根据count的值做处理
if (count > 0) {
// 把缓存区的数据转成字符串
String msg = new String(buffer.array());
// 输出该消息
System.out.println("form 客户端: " + msg);
// 向其它的客户端转发消息(去掉自己), 专门写一个方法来处理
sendInfoToOtherClients(msg, channel);
}
} catch (IOException e) {
try {
System.out.println(channel.getRemoteAddress() + " 离线了..");
// 取消注册
key.cancel();
} catch (IOException ioException) {
ioException.printStackTrace();
} finally {
// 关闭通道
try {
channel.close();
} catch (IOException ioException) {
ioException.printStackTrace();
}
}
}
}

/**
* 向其它的客户端转发消息(去掉自己)
* @param msg 转发的消息
* @param self 自己
*/
private void sendInfoToOtherClients(String msg, SocketChannel self) {
System.out.println("服务器转发消息中...");
System.out.println("服务器转发数据给客户端线程: " + Thread.currentThread().getName());
// 遍历 所有注册到selector 上的 SocketChannel,并排除 self
for (SelectionKey key : selector.keys()) {
//通过 key 取出对应的 SocketChannel
Channel targetChannel = key.channel();

//排除自己
if (targetChannel instanceof SocketChannel && targetChannel != self) {
try {
// 转型
SocketChannel dest = (SocketChannel) targetChannel;
// 将msg 存储到buffer
ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
// 将buffer 的数据写入 通道
dest.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

public static void main(String[] args) {
//创建服务器对象
GroupChatServer groupChatServer = new GroupChatServer();
groupChatServer.listen();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package com.awo.nio.groupchat;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;

public class GroupChatClient {

//定义相关的属性
private static final String HOST = "127.0.0.1";
private static final int PORT = 6667;
private SocketChannel socketChannel;
private Selector selector;
private String username;

//构造器, 完成初始化工作


public GroupChatClient() {
try {
socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT));
socketChannel.configureBlocking(false);
selector = Selector.open();
socketChannel.register(selector, SelectionKey.OP_READ);
username = socketChannel.getLocalAddress().toString().substring(1);
System.out.println(username + " is ok...");
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 向服务器发送消息
*
* @param info
*/
public void sendInfo(String info) {
info = username + " 说:" + info;
try {
socketChannel.write(ByteBuffer.wrap(info.getBytes()));
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 读取从服务器端回复的消息
*/
public void readInfo() {
try {
int readChannels = selector.select();
// 有可以用的通道
if (readChannels > 0) {
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isReadable()) {
// 得到相关的通道
SocketChannel channel = (SocketChannel) key.channel();
// 得到一个Buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 读取
channel.read(buffer);
// 把读到的缓冲区的数据转成字符串
String msg = new String(buffer.array());
System.out.println(msg.trim());
}
// 删除当前的selectionKey, 防止重复操作
iterator.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
GroupChatClient groupChatClient = new GroupChatClient();
// 启动一个线程, 每个3秒,读取从服务器发送数据
new Thread(() -> {
while (true) {
groupChatClient.readInfo();
try {
Thread.sleep(3000);
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();

// 发送数据给服务器端
Scanner scanner = new Scanner(System.in);
while (scanner.hasNextLine()) {
String s = scanner.nextLine();
groupChatClient.sendInfo(s);
}
}
}

12、NIO与零拷贝

1、零拷贝基本介绍

  1. 零拷贝是网络编程的关键,很多性能优化都离不开零拷贝。
  2. 在 Java 程序中,常用的零拷贝有 mmap(内存映射)sendFile。那么,他们在 OS 里,到底是怎么样的一个的设计?我们分析 mmap 和 sendFile 这两个零拷贝
  3. 另外我们看下NIO 中如何使用零拷贝

2、传统IO数据读写

代码:

1
2
3
4
5
6
7
8
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");

byte[] arr = new byte[(int) file.length()];
raf.read(arr);

Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

3、传统IO模型

image-20210818031152788

注意:

  1. DMA:direct memory access——直接内存拷贝(不使用CPU)
  2. 这个IO经过了四次拷贝(两次CPU拷贝、两次DMA拷贝)和三次状态切换(用户态->内核态->用户态->内核态),代价较高
    • 四次拷贝
      1. 第一次: 从硬盘 经过 DMA 拷贝 到 kernel buffer (内核buferr)
      2. 第二次: 从kernel buffer 经过cpu 拷贝到 user buffer,比如拷贝到应用程序
      3. 第三次: 从user buffer 拷贝到 socket buffer
      4. 第四次: 从socket buffer 拷贝到 protocol engine 协议栈
    • 三次状态切换
      1. 第一次状态切换: 用户态 —> 内核态 (或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
      3. 第三次状态切换: 用户态—> 内核态
  3. 有一个观点认为状态切换变成了四次(最后需要从内核态切换为用户态)
    • 第四次状态切换:内核态—> 用户态

4、mmap优化

mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数

image-20210818032518521

注意:

  1. 通过mmap内存映射优化之后,拷贝次数变成了3次,状态切换还是3次
    • 三次拷贝
      1. 第一次拷贝: DMA拷贝,从硬件拷贝到内核空间
        • 因为user buffer 与kernel buffer共享数据 ,所以不需要将数据从kernel buffer 拷贝到 user buffer , 数据可以直接在内核空间修改
      2. 第二次拷贝: kernel buffer 中的数据经过 cpu 拷贝到 socket buffer
      3. 第三次拷贝: socket buffer 过DMA拷贝到protocol engine 协议栈
    • 三次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
      3. 第三次状态切换: 用户态—> 内核态
  2. 有一个观点认为状态切换变成了四次(最后需要从内核态切换为用户态)
    • 第四次状态切换:内核态—> 用户态

5、sendFile优化

Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。具体如下图和小结:

image-20210818033948497

注意:

  • Linux 2.1 版本中,通过sendFile优化之后,拷贝次数变成了3次,状态切换还是2次
    • 三次拷贝
      1. 第一次拷贝: DMA拷贝,从硬件拷贝到内核空间
      2. 第二次拷贝: kernel buffer 中的数据经过 cpu 拷贝到 socket buffer
      3. 第三次拷贝: socket buffer 过DMA拷贝到protocol engine 协议栈
    • 两次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
        • 由于和用户态完全无关,所以就不用切换到用户态后再切换到内核态了,减少了一次上下文切换

注:

  • 零拷贝从操作系统角度,是没有cpu 拷贝(DMA不可避免)
  • Linux 2.1 版本 提供了 sendFile 函数并没有完全实现零拷贝(存在CPU拷贝)

Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。具体如下图和小结:

image-20210818040744312

注意:

  • Linux 在 2.4 版本中,通过sendFile优化之后,拷贝次数变成了2次,状态切换还是2次
    • 两次拷贝
      1. 第一次拷贝: DMA拷贝,将数据从硬盘拷贝到kernel buffer
      2. 第二次拷贝: DMA拷贝,将数据从kernel buffer拷贝到protocol engine
        • 没有经过cpu拷贝,也就是操作系统级别的拷贝,实现了真正的零拷贝
    • 两次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态

注:

  1. Linux2.4 提供的sendFile实现了真正的零拷贝
  2. 这里其实有 一次cpu 拷贝 kernel buffer -> socket buffer 但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略

6、零拷贝的再次理解

  1. 我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)
  2. 零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

7、mmap与sendFile的区别

  1. mmap 适合小数据量读写,sendFile 适合大文件传输。
  2. mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
  3. sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。

8、NIO零拷贝案例

案例要求:

  1. 使用传统的IO 方法传递一个大文件
  2. 使用NIO 零拷贝方式传递(transferTo)一个大文件
  3. 看看两种传递方式耗时时间分别是多少

代码:

传统IO的服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;

//java IO 的服务器
public class OldIOServer {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(7001);

while (true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

try {
byte[] byteArray = new byte[4096];

while (true) {
int readCount = dataInputStream.read(byteArray, 0, byteArray.length);

if (-1 == readCount) {
break;
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}

传统IO的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;

//java IO 的服务器
public class OldIOServer {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(7001);

while (true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

try {
byte[] byteArray = new byte[4096];

while (true) {
int readCount = dataInputStream.read(byteArray, 0, byteArray.length);

if (-1 == readCount) {
break;
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}

零拷贝的服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

//服务器
public class NewIOServer {
public static void main(String[] args) throws Exception {

InetSocketAddress address = new InetSocketAddress(7001);

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

ServerSocket serverSocket = serverSocketChannel.socket();

serverSocket.bind(address);

//创建buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();

int readcount = 0;
while (-1 != readcount) {
try {

readcount = socketChannel.read(byteBuffer);

}catch (Exception ex) {
// ex.printStackTrace();
break;
}
//
byteBuffer.rewind(); //倒带 position = 0 mark 作废
}
}
}
}

零拷贝的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;

public class NewIOClient {
public static void main(String[] args) throws Exception {

SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "protoc-3.6.1-win32.zip";

//得到一个文件channel
FileChannel fileChannel = new FileInputStream(filename).getChannel();

//准备发送
long startTime = System.currentTimeMillis();

//在linux下一个transferTo 方法就可以完成传输
//在windows 下 一次调用 transferTo 只能发送8m , 就需要分段传输文件, 而且要主要
//传输时的位置 =》 课后思考...
//transferTo 底层使用到零拷贝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);

System.out.println("发送的总的字节数 =" + transferCount + " 耗时:" + (System.currentTimeMillis() - startTime));

//关闭
fileChannel.close();

}
}

结果:

传统IO:

1
发送的总的字节数 = 1,007,473 耗时:60

零拷贝:

1
发送的总的字节数 = 1,007,473 耗时:21

4、Java AIO 以及 三种IO模型的对比

1、Java AIO 基本介绍

  1. JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 编程中,常用到两种模式:ReactorProactorJava 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理
  2. AIO 即 NIO2.0,叫做异步不阻塞的 IO。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用
  3. 目前 AIO 还没有广泛应用,Netty 也是基于NIO, 而不是AIO, 因此我们就不详解AIO了,有兴趣的同学可以参考 <<Java新一代网络编程模型AIO原理及Linux系统AIO介绍>>

2、BIO、NIO、AIO对比表

BIO NIO AIO
IO 模型 同步阻塞 同步非阻塞(多路复用) 异步非阻塞
编程难度 简单 复杂 复杂
可靠性
可靠性

举例说明:

  1. 同步阻塞:到理发店理发,就一直等理发师,直到轮到自己理发。
  2. 同步非阻塞:到理发店理发,发现前面有其它人理发,给理发师说下,先干其他事情,一会过来看是否轮到自己。
  3. 异步非阻塞:给理发师打电话,让理发师上门服务,自己干其它事情,理发师自己来家给你理发

4、Netty概述

1、原生NIO存在的问题

  1. NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
  2. 需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的 NIO 程序。
  3. 开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流的处理等等。
  4. JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。直到 JDK 1.7 版本该问题仍旧存在,没有被根本解决。

2、Netty官网

Netty官网上的说明:

Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients

image-20210818173529694

3、Netty官网说明

  • Netty 是由 JBOSS 提供的一个 Java 开源框架。Netty 提供==异步==的、==基于事件驱动==的网络应用程序框架,用以快速开发高性能、高可靠性的网络 IO 程序
  • Netty 可以帮助你快速、简单的开发出一个网络应用,相当于简化和流程化了 NIO 的开发过程
  • Netty 是目前最流行的 NIO 框架,Netty 在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,知名的 Elasticsearch 、Dubbo 框架内部都采用了 Netty。

4、Netty的优点

Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。

  1. 设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池。
  2. 使用方便:详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK 5(Netty 3.x)或 6(Netty 4.x)就足够了。
  3. 高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。
  4. 安全:完整的 SSL/TLS 和 StartTLS 支持。
  5. 社区活跃、不断更新:社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入

5、Netty版本说明

  1. netty版本分为 netty3.x 和 netty4.x、netty5.x
  2. 因为Netty5出现重大bug,已经被官网废弃了,目前推荐使用的是Netty4.x的稳定版本
  3. 目前在官网可下载的版本 netty3.x netty4.0.x 和 netty4.1.x
  4. 本次以 Netty4.1.x 版本为主
  5. netty 下载地址

5、Netty 高性能架构设计

1、线程模型基本介绍

  1. 不同的线程模式,对程序的性能有很大影响,为了搞清Netty 线程模式,我们来系统的讲解下各个线程模式, 最后看看Netty 线程模型有什么优越性。
  2. 目前存在的线程模型有:
    • 传统阻塞 I/O 服务模型
    • Reactor 模式
  3. 根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现
    • 单 Reactor 单线程
    • 单 Reactor 多线程
    • 主从 Reactor 多线程
  4. Netty 线程模式(Netty 主要基于主从 Reactor 多线程模型做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor)

2、传统阻塞 I/O 服务模型

1、工作原理图

image-20210818180105435

黄色的框表示对象, 蓝色的框表示线程,白色的框表示方法(API)

2、模型特点

  1. 采用阻塞IO模式获取输入的数据
  2. 每个连接都需要独立的线程完成数据的输入,业务处理,数据返回

3、问题分析

  1. 当并发数很大,就会创建大量的线程,占用很大系统资源
  2. 连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在read 操作,造成线程资源浪费

3、Reactor 模式(整体)

1、针对传统阻塞 I/O 服务模型的 2 个缺点,解决方案

  1. ==基于 I/O 复用模型==:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接。当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理
    • `Reactor 对应的叫法:
      • 反应器模式
      • 分发者模式(Dispatcher)
      • 通知者模式(notifier)
  2. ==基于线程池复用线程资源==:不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个线程可以处理多个连接的业务。

image-20210818181829563

2、工作原理图

I/O 复用结合线程池,就是 Reactor 模式基本设计思想,如图

image-20210818181932485

3、模型特点

  1. Reactor 模式,通过一个或多个输入同时传递给服务处理器的模式(基于事件驱动)
  2. 服务器端程序处理传入的多个请求,并将它们同步分派到相应的处理线程, 因此Reactor模式也叫 Dispatcher模式
  3. Reactor 模式使用IO复用监听事件,收到事件后,分发给某个线程(进程),这点就是网络服务器高并发处理关键

4、Reactor 模式中 核心组成

  • ReactorReactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
  • Handlers处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。

5、Reactor 模式分类

根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:

  1. 单 Reactor 单线程
  2. 单 Reactor 多线程
  3. 主从 Reactor 多线程

4、单 Reactor 单线程

1、工作原理图

image-20210818182434248

2、原理图说明
  1. Select 是前面 I/O 复用模型介绍的标准网络编程 API,可以实现应用程序通过一个阻塞对象监听多路连接请求
  2. Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发
  3. 如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理
  4. 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应
  5. Handler 会完成 Read→业务处理→Send 的完整业务流程

结合实例:服务器端用一个线程通过多路复用搞定所有的 IO 操作(包括连接,读、写等),编码简单,清晰明了,但是如果客户端连接数量较多,将无法支撑,前面的 NIO 案例就属于这种模型。

3、单 Reactor 单线程的优缺点
  • 优点:
    • 模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成
  • 缺点:
    • 性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈
    • 可靠性问题,线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障
  • 使用场景:
    • 客户端的数量有限,业务处理非常快速,比如 Redis在业务处理的时间复杂度 O(1) 的情况

5、单Reactor 多线程

1、工作原理图

image-20210818194738294

2、原理图说明

  1. Reactor 对象通过select 监控客户端请求事件,收到事件后,通过dispatch进行分发
  2. 如果建立连接请求,则由 Acceptor 通过accept 处理连接请求,然后创建一个Handler对象处理完成连接后的各种事件
  3. 如果不是连接请求,则由reactor分发调用连接对应的handler 来处理
  4. handler 只负责响应事件,不做具体的业务处理,通过read 读取数据后,会分发给后面的worker线程池的某个线程处理业务
  5. worker 线程池会分配独立线程完成真正的业务,并将结果返回给handler
  6. handler收到响应后,通过send 将结果返回给client

3、单Reactor 多线程的优缺点

  • 优点:可以充分的利用多核cpu 的处理能力
  • 缺点:多线程数据共享和访问比较复杂, reactor 处理所有的事件的监听和响应,在单线程运行, 在高并发场景容易出现性能瓶颈。

6、主从 Reactor 多线程

1、工作原理图

image-20210818195224698

针对单 Reactor 多线程模型中,Reactor 在单线程中运行,高并发场景下容易成为性能瓶颈,可以让 Reactor 在多线程中运行

2、原理图说明

  1. Reactor主线程 MainReactor 对象通过select 监听连接事件,收到事件后,通过Acceptor 处理连接事件
  2. 当 Acceptor 处理连接事件后,MainReactor 将连接分配给SubReactor
  3. subreactor 将连接加入到连接队列进行监听,并创建handler进行各种事件处理
  4. 当有新事件发生时, subreactor 就会调用对应的handler处理
  5. handler 通过read 读取数据,分发给后面的worker 线程处理
  6. worker 线程池分配独立的worker 线程进行业务处理,并返回结果
  7. handler 收到响应的结果后,再通过send 将结果返回给client
  8. Reactor 主线程可以对应多个Reactor 子线程,即MainRecator 可以关联多个SubReactor

3、Scalable IO in Java 对 Multiple Reactors 的原理图解

image-20210818195414795

4、主从 Reactor 多线程的优缺点

  • 优点:
    • 父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。
    • 父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。
  • 缺点:
    • 编程复杂度较高
  • 结合实例:这种模型在许多项目中广泛使用,包括 Nginx 主从 Reactor 多进程模型,Memcached 主从多线程,Netty 主从多线程模型的支持

7、Reactor 模式小结

1、3 种模式用生活案例来理解

  1. 单 Reactor 单线程,前台接待员和服务员是同一个人,全程为顾客服务
  2. 单 Reactor 多线程,1 个前台接待员,多个服务员,接待员只负责接待
  3. 主从 Reactor 多线程,多个前台接待员,多个服务生

2、Reactor 模式具有如下的优点

  • 响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的
  • 可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销
  • 扩展性好,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源
  • 复用性好,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性

8、Netty模型

1、工作原理图1——简单版

Netty 主要基于主从 Reactors 多线程模型(如图)做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor。

image-20210818210506772

  1. BossGroup 线程维护 Selector ,只关注 Accecpt 事件
  2. 当接收到Accept事件,获取到对应的SocketChannel,封装成 NIOScoketChannel并注册到Worker 线程(事件循环),并进行维护
  3. 当Worker线程监听到 selector 中通道发生自己感兴趣的事件后,就进行处理(就由handler进行处理), 注意handler 已经加入到通道

2、工作原理图2——进阶版

image-20210818213124808

Netty 主要基于主从 Reactors 多线程模型(如图)做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor

3、工作原理图3——详细版

image-20210818213257551

4、原理图说明

  1. Netty抽象出两组线程池:
    • BossGroup:专门负责接收客户端的连接
    • WorkerGroup: 专门负责网络的读写
  2. BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup
  3. NioEventLoopGroup 相当于一个事件循环组, 这个组中含有多个事件循环 ,每一个事件循环是 NioEventLoop
  4. NioEventLoop 表示一个不断循环的执行处理任务的线程, 每个NioEventLoop 都有一个selector,用于监听绑定在其上的socket的网络通讯
  5. NioEventLoopGroup 可以有多个线程,即可以含有多个NioEventLoop
  6. 每个Boss NioEventLoop 循环执行的步骤有3步
    1. 轮询accept 事件
    2. 处理accept 事件,与client建立连接,生成NioScocketChannel,并将其注册到某个worker NIOEventLoop 上的 selector
    3. 处理任务队列的任务,即 runAllTasks
  7. 每个 Worker NIOEventLoop 循环执行的步骤
    1. 轮询read/write 事件
    2. 处理i/o事件,即read/write 事件,在对应NioScocketChannel上进行处理
    3. 处理任务队列的任务 , 即 runAllTasks
  8. 每个Worker NIOEventLoop 处理业务时,会使用pipeline(管道),pipeline 中包含了 channel,即通过pipeline 可以获取到对应通道,管道中维护了很多的处理器(可对数据进行相关的拦截与过滤等等)。

5、Netty快速入门实例——TCP服务

  1. 实例要求:使用IDEA 创建Netty项目
  2. Netty 服务器在 6668 端口监听,客户端能发送消息给服务器 “hello, 服务器~”
  3. 服务器可以回复消息给客户端 “hello, 客户端~”
  4. 目的:对Netty 线程模型 有一个初步认识,便于理解Netty 模型理论
    1. 编写服务端
    2. 编写客户端
    3. 对netty 程序进行分析,看看netty模型特点

代码:

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package com.awo.netty.simple;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class NettyServer {

public static void main(String[] args) {

EventLoopGroup bossGroup = null;
EventLoopGroup workerGroup = null;

try {
//创建BossGroup 和 WorkerGroup
//说明
//1. 创建两个线程组 bossGroup 和 workerGroup
//2. bossGroup 只是处理连接请求 , 真正的和客户端业务处理,会交给 workerGroup完成
//3. 两个都是无限循环
//4. bossGroup 和 workerGroup 含有的子线程(NioEventLoop)的个数
// 默认实际 cpu核数 * 2
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();

//创建服务器端的启动对象,配置参数
ServerBootstrap bootstrap = new ServerBootstrap();
//使用链式编程来进行设置
bootstrap.group(bossGroup, workerGroup) //设置两个线程组
.channel(NioServerSocketChannel.class) //使用NioSocketChannel 作为服务器的通道实现
.option(ChannelOption.SO_BACKLOG,128) // 设置线程队列得到连接个数
.childOption(ChannelOption.SO_KEEPALIVE,true) //设置保持活动连接状态
// .handler(null) // 该 handler对应 bossGroup , childHandler 对应 workerGroup
.childHandler(new ChannelInitializer<SocketChannel>() { //创建一个通道初始化对象(匿名对象)
//给pipeline 设置处理器
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//可以使用一个集合管理 SocketChannel, 再推送消息时,可以将业务加入到各个channel 对应的 NIOEventLoop 的 taskQueue 或者 scheduleTaskQueue
System.out.println("客户socketchannel hashcode=" + socketChannel.hashCode());
socketChannel.pipeline().addLast(new NettyServerHandler());
}
}); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器

System.out.println(".....服务器 is ready...");

//绑定一个端口并且同步, 生成了一个 ChannelFuture 对象
//启动服务器(并绑定端口)
ChannelFuture channelFuture = bootstrap.bind(6668).sync();

//给ChannelFuture注册监听器,监控我们关心的事件
channelFuture.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
if (channelFuture.isSuccess()) {
System.out.println("监听端口 6668 成功");
} else {
System.out.println("监听端口 6668 失败");
}
}
});

//对关闭通道进行监听
channelFuture.channel().closeFuture().sync();

} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
}
}
}

服务端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.awo.netty.simple;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;

/**
* 说明:
* 1、我们自定义一个Handler 需要继承netty 规定好的某个HandlerAdapter(规范)
* 2、这时我们自定义一个Handler , 才能称为一个handler
*/
public class NettyServerHandler extends ChannelInboundHandlerAdapter {

/**
* 读取数据实际(这里我们可以读取客户端发送的消息)
* 1. ChannelHandlerContext ctx:上下文对象, 含有 管道pipeline , 通道channel, 地址
* 2. Object msg: 就是客户端发送的数据 默认Object
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//将 msg 转成一个 ByteBuf
//ByteBuf 是 Netty 提供的,不是 NIO 的 ByteBuffer.
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println("客户端发送消息是:" + byteBuf.toString(CharsetUtil.UTF_8));
System.out.println("客户端地址:" + ctx.channel().remoteAddress());
}

/**
* 数据读取完毕
* @param ctx
* @throws Exception
*/
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
//writeAndFlush 是 write + flush
//将数据写入到缓存,并刷新
//一般讲,我们对这个发送的数据进行编码
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵",CharsetUtil.UTF_8));
}

/**
* 处理异常, 一般是需要关闭通道
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.awo.netty.simple;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;

public class NettyClient {
public static void main(String[] args) {
//客户端需要一个事件循环组
EventLoopGroup group = new NioEventLoopGroup();
try {
//创建客户端启动对象
//注意客户端使用的不是 ServerBootstrap 而是 Bootstrap
Bootstrap bootstrap = new Bootstrap();
//设置相关参数
bootstrap.group(group) //设置线程组
.channel(NioSocketChannel.class) // 设置客户端通道的实现类(反射)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//加入自己的处理器
socketChannel.pipeline().addLast(new NettyClientHandler());
}
});

System.out.println("客户端 ok..");
//启动客户端去连接服务器端
//关于 ChannelFuture 要分析,涉及到netty的异步模型
ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 6668).sync();

//给关闭通道进行监听
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
group.shutdownGracefully();
}
}
}

客户端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.awo.netty.simple;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;

public class NettyClientHandler extends ChannelInboundHandlerAdapter {
/**
* 当通道就绪就会触发该方法
*
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, server: (>^ω^<)喵", CharsetUtil.UTF_8));
}

/**
* 当通道有读取事件时,会触发
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
System.out.println("服务器回复的消息:" + buf.toString(CharsetUtil.UTF_8));
System.out.println("服务器的地址: "+ ctx.channel().remoteAddress());
}

/**
* 处理异常, 一般是需要关闭通道
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

6、相关问题以及解答

问题1:bossGroup与workerGroup含有的子线程的数量

解答:默认为CPU核数的两倍,即CPU核数*2

相关源码:

1
2
3
4
5
6
7
8
9
10
11
12
// NioEventLoopGroup构造函数(空参)
public NioEventLoopGroup() {
this(0);
}
// 最终调用了其父类MultithreadEventLoopGroup的构造方法
protected MultithreadEventLoopGroup(int nThreads, Executor executor, Object... args) {
// 这里的nThread就是上面this的参数
super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, executor, args);
}
// DEFAULT_EVENT_LOOP_THREADS相关说明
// 其中的NettyRuntime.availableProcessors()返回的就是CPU核数,然后乘以2返回
private static final int DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt("io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2));
问题2:Netty服务端接收的新连接是如何绑定到worker线程池的(即worker线程池是怎么分配线程的)?

解答:通过==轮询==的方式,(假设worker线程的个数为8)首先为第一个连接分配线程1,接着为第二个连接分配线程2……然后为第八个连接分配线程8;如果之后还有连接到来的的话,在线程1空闲的情况下,会分配线程1到第九个连接。

问题3:ctx(上下文对象)里面包含的内容

image-20210819024626333

ctx实际上是一个数据流,有着出站与入站(inbound 入站 ,outbound 出站)

问题4:channel与 pipeline 之间的关系

pipeline(管道) 本质上是一个双向链表,有着头尾指针。一个pipeline 与一个 channel对应,可以通过pipeline 获取到它对应的channel

image-20210819025333778

channel(通道) :其中也包含了与channel对应的pipeline对象

image-20210819025741518

7、任务队列中的 Task 有 3 种典型使用场景

如果当前有一个非常耗时长(长时间的操作)的业务,如果正常地放在handler中去执行的话,势必会造成pipeline的阻塞。因此,对于某些任务的执行可以提交到NioEventLoop的TaskQueue任务队列中去异步执行。其实TaskQueue与Channel之间存在绑定关系。对于这些任务有以下3种典型的应用:

  1. 用户程序自定义的普通任务
  2. 用户自定义定时任务
  3. 非当前 Reactor 线程调用 Channel 的各种方法

例如在推送系统的业务线程里面,根据用户的标识,找到对应的 Channel 引用,然后调用 Write 类方法向该用户推送消息,就会进入到这种场景。最终的 Write 会提交到任务队列中后被异步消费

将这些任务从handler中提交到channel对应的NIOEventLoop 的 TaskQueue的方法:

1、用户程序自定义的普通任务 -> 提交到该channel 对应的NioEventLoop 的 taskQueue中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 用户程序自定义的普通任务
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵2", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});

/*
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(20 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵3", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});
*/

注意:

  • 该方法是通过ctx获得channel对象,在通过channel对象去获取该channel所在的evevtLoop,最后在将任务提交到eventLoop的taskQueue中

  • eventLoop会起一个线程去异步解决taskQueue当中的任务,==注意是一个线程==。如果taskQueue当中有多个任务的话,那么该线程会按照taskQueue中任务的顺序依次执行任务,即执行taskQueue任务的时间是累加的

    • eg:taskQueue的第一个任务花费10s,taskQueue的第二个任务花费20s,那么该线程执行完taskQueue当中的任务总共要花费30s
  • 解决方法:

    1. 在当前Handler中创建一个业务线程池,把耗时任务放到创建的线程池中执行。此时就变成了一个线程有一个业务线程池,来完成耗时任务的异步操作。(局部异步)

      • 创建线程池的方法:

        • // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);
          
          // 调用一下方法将耗时任务放在线程池创建的线程中进行执行
          group.sumbit(Callable task);
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15

          2. **在Server端中创建一个业务线程池(Context中添加线程池)**(整个异步)

          - 创建线程池的方法:

          - ```java
          // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);

          // 在ChannelInitializer的initChannel方法中
          ChannelPipeline p = chpipeline();
          // 在这里将group设置进去:如果这样设置的话,该handler会优先加入到该线程池中,这样一来,workerGroup主要接收任务 然后在将任务提交给线程池来处理。
          // 默认没添加group的话,handler会进入workerLoopGroup的某一个workerLoop子线程
          p.addLast(group,new MyServerHandler());
2、用户自定义定时任务 -> 提交到该channel 对应的NioEventLoop 的 scheduleTaskQueue中
1
2
3
4
5
6
7
8
9
10
11
12
13
ctx.channel().eventLoop().schedule(new Runnable() {
@Override
public void run() {

try {
Thread.sleep(5 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵4", CharsetUtil.UTF_8));
System.out.println("channel code=" + ctx.channel().hashCode());
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
}, 5, TimeUnit.SECONDS);

注意:

  • 该任务在5s后执行
  • sleep 需要占用线程资源,这5s线程啥都干不了,延时5s执行任务,这5s线程可以做别的事情
  • taskQueue里的任务执行完毕后,会再执行scheduledtaskQueue。并且scheduled里的延迟时间是从taskQueue执行第一个任务之前开始算的
  • 并且如果scheduled延迟时间若小于taskQueue里的总执行时间,在后者执行完后前者会立即执行,而不会在后者运行期间执行前者。
  • 以上代码只是一个延迟任务,如果是定时任务的话还少了个参数,在第一个数字(延迟时间)后加一个间隔时间
3、非当前 Reactor 线程调用 Channel 的各种方法
1
2
3
4
5
6
7
8
9
10
// 在Server端的ServerBootstrap的配置中的childHandler进行初始化的时候就可以将客户端的SocketChannel维护在一个集合里面,方便之后的获取
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {//创建一个通道初始化对象(匿名对象)
//给pipeline 设置处理器
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//可以使用一个集合管理 SocketChannel, 再推送消息时,可以将业务加入到各个channel 对应的 NIOEventLoop 的 taskQueue 或者 scheduleTaskQueue
System.out.println("客户socketchannel hashcode=" + ch.hashCode());
ch.pipeline().addLast(new NettyServerHandler());
}
}); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器

8、方案再说明

  1. Netty 抽象出两组线程池BossGroup 专门负责接收客户端连接WorkerGroup 专门负责网络读写操作
  2. NioEventLoop 表示一个不断循环执行处理任务的线程,每个 NioEventLoop 都有一个 selector,用于监听绑定在其上的 socket 网络通道
  3. NioEventLoop 内部采用==串行化设计==,从消息的读取->解码->处理->编码->发送,始终由 IO 线程 NioEventLoop 负责(如果在处理方面需要花费长时间的话就会阻塞这个流程,所以通常将花费长时间的任务放在taskQueue当中取异步执行)
    • NioEventLoopGroup 下包含多个 NioEventLoop
    • 每个 NioEventLoop 中包含有一个 Selector,一个 taskQueue
    • 每个 NioEventLoop 的 Selector 上可以注册监听多个 NioChannel
    • 每个 NioChannel 只会绑定在唯一的 NioEventLoop 上
    • 每个 NioChannel 都绑定有一个自己的 ChannelPipeline

6、异步模型

1、基本介绍

  1. 异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的组件在完成后,通过状态、通知和回调来通知调用者。
  2. Netty 中的 I/O 操作是异步的,包括 Bind、Write、Connect 等操作会简单的返回一个 ChannelFuture。
  3. 调用者并不能立刻获得结果,而是通过 ==Future-Listener 机制==,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果
  4. Netty 的异步模型是建立在 future 和 callback 的之上的。callback 就是回调。重点说 Future,它的核心思想是:假设一个方法 fun,计算过程可能非常耗时,等待 fun返回显然不合适。那么可以在调用 fun 的时候,立马返回一个 Future,后续可以通过 Future去监控方法 fun 的处理过程(即 : Future-Listener 机制)

2、Future说明

  1. 表示异步的执行结果,可以通过它提供的方法来检测执行是否完成,比如检索计算等等。

  2. ChannelFuture 是一个接口,我们可以添加监听器,当监听的事件发生时,就会通知到监听器。

    • public interface ChannelFuture extends Future<Void> {}
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40



      ### 3、工作原理图

      ![image-20210819212841178](Netty/image-20210819212841178.png)

      ![image-20210819212905885](Netty/image-20210819212905885.png)

      说明:

      1. 在使用 Netty 进行编程时,拦截操作和转换出入站数据只需要您提供 callback 或利用future 即可。这使得**链式操作**简单、高效, 并有利于编写可重用的、通用的代码。
      2. Netty 框架的目标就是让你的业务逻辑从网络基础应用编码中分离出来、解脱出来



      ### 4、Future-Listener 机制

      1. 当 Future 对象刚刚创建时,处于非完成状态,调用者可以通过返回的 ChannelFuture 来获取操作执行的状态,注册监听函数来执行完成后的操作。

      2. 常见有如下操作

      - 通过 `isDone` 方法来**判断当前操作是否完成**;
      - 通过 `isSuccess` 方法来**判断已完成的当前操作是否成功**;
      - 通过 `getCause` 方法来**获取已完成的当前操作失败的原因**;
      - 通过 `isCancelled` 方法来**判断已完成的当前操作是否被取消**;
      - 通过 `addListener` 方法来注册监听器,**当操作已完成(isDone 方法返回完成),将会通知指定的监听器;如果 Future 对象已完成,则通知指定的监听器**

      3. 举例说明

      - 演示:绑定端口是异步操作,当绑定操作处理完,将会调用相应的监听器处理逻辑

      - ```java
      serverBootstrap.bind(port).addListener(future -> {
      if(future.isSuccess()) {
      System.out.println(newDate() + ": 端口["+ port + "]绑定成功!");
      } else{
      System.err.println("端口["+ port + "]绑定失败!");
      }
      });

小结:相比传统阻塞 I/O,执行 I/O 操作后线程会被阻塞住,直到操作完成;异步处理的好处是不会造成线程阻塞,线程在 I/O 操作期间可以执行别的程序,在高并发情形下会更稳定和更高的吞吐量。

5、快速入门实例——HTTP服务

  1. 实例要求:使用IDEA 创建Netty项目
  2. Netty 服务器在 7777端口监听,浏览器发出请求 “http://localhost:7777/
  3. 服务器可以回复消息给客户端 “Hello! 我是服务器 5 “ ,并对特定请求资源进行过滤。
  4. 目的:Netty 可以做Http服务开发,并且理解Handler实例和客户端及其请求的关系。
  5. 效果:
    • image-20210819213544007

代码:

Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.awo.netty.http;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class TestServer {
public static void main(String[] args) {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class).childHandler(new TestServerInitializer());
ChannelFuture channelFuture = serverBootstrap.bind(7777).sync();
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}

TestServerInitializer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.awo.netty.http;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpServerCodec;

public class TestServerInitializer extends ChannelInitializer<SocketChannel> {

@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//向管道加入处理器

//得到管道
ChannelPipeline pipeline = socketChannel.pipeline();
//加入一个netty 提供的httpServerCodec codec =>[coder - decoder]
//HttpServerCodec 说明
//1. HttpServerCodec 是netty 提供的处理http的 编-解码器
pipeline.addLast("MyHttpServerCodec", new HttpServerCodec());
//2. 增加一个自定义的handler
pipeline.addLast("MyTestHttpServerHandler", new TestHttpServerHandler());
System.out.println("ok~~~~");
}
}

TestHttpServerHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.awo.netty.http;


import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;

import java.net.URI;

/**
* 说明
* 1. SimpleChannelInboundHandler 是 ChannelInboundHandlerAdapter
* 2. HttpObject 客户端和服务器端相互通讯的数据被封装成 HttpObject
*/
public class TestHttpServerHandler extends SimpleChannelInboundHandler<HttpObject> {

/**
* channelRead0 读取客户端数据
*
* @param ctx
* @param msg
* @throws Exception
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws Exception {

System.out.println("对应的channel=" + ctx.channel() + " pipeline=" + ctx
.pipeline() + " 通过pipeline获取channel" + ctx.pipeline().channel());

System.out.println("当前ctx的handler=" + ctx.handler());

//判断 msg 是不是 httprequest请求
if(msg instanceof HttpRequest) {

System.out.println("ctx 类型="+ctx.getClass());

System.out.println("pipeline hashcode" + ctx.pipeline().hashCode() + " TestHttpServerHandler hash=" + this.hashCode());

System.out.println("msg 类型=" + msg.getClass());
System.out.println("客户端地址" + ctx.channel().remoteAddress());

//获取到
HttpRequest httpRequest = (HttpRequest) msg;
//获取uri, 过滤指定的资源
URI uri = new URI(httpRequest.uri());
if("/favicon.ico".equals(uri.getPath())) {
System.out.println("请求了 favicon.ico, 不做响应");
return;
}
//回复信息给浏览器 [http协议]

ByteBuf content = Unpooled.copiedBuffer("hello, 我是服务器", CharsetUtil.UTF_8);

//构造一个http的相应,即 httpresponse
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, content);

response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json");
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());

//将构建好 response返回
ctx.writeAndFlush(response);
}
}
}

注意:

  • Http是无状态协议,而且建立的一般都是长链接,所以在刷新浏览器后,服务端会为本次的http请求创建新的handler和pipeline(一个handler与一个pipeline对应,为一组。多个http请求就会有多组handler与pipeline)

7、Netty 核心模块组件

1、Bootstrap、ServerBootstrap

  • Bootstrap 意思是引导,一个 Netty 应用通常由一个 Bootstrap 开始,主要作用是配置整个 Netty 程序,串联各个组件。
  • Netty 中 Bootstrap 类是==客户端==程序的启动引导类,ServerBootstrap 是==服务端==启动引导类

常见的方法有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 该方法用于服务器端,用来设置两个 EventLoop
public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup);

// 该方法用于客户端,用来设置一个 EventLoop
public B group(EventLoopGroup group);

// 该方法用来设置一个服务器端的通道实现
public B channel(Class<? extends C> channelClass);

// 用来给 ServerChannel 添加配置
public <T> B option(ChannelOption<T> option, T value);

// 用来给接收到的通道添加配置
public <T> ServerBootstrap childOption(ChannelOption<T> childOption, T value);

// 该方法用来设置业务处理类(自定义的 handler)
// handler对应 bossGroup , childHandler 对应 workerGroup
public ServerBootstrap childHandler(ChannelHandler childHandler);

// 该方法用于服务器端,用来设置占用的端口号
public ChannelFuture bind(int inetPort);

// 该方法用于客户端,用来连接服务器端
public ChannelFuture connect(String inetHost, int inetPort);

2、Channel

  1. Netty 网络通信的组件,能够用于执行网络 I/O 操作。
  2. 通过Channel 可获得 当前网络连接的通道的状态
  3. 通过Channel 可获得 网络连接的配置参数 (例如接收缓冲区大小)
  4. Channel 提供异步的网络 I/O 操作(如建立连接,读写,绑定端口),异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用结束时所请求的 I/O 操作已完成
  5. 调用立即返回一个 ChannelFuture 实例,通过注册监听器到 ChannelFuture 上,可以 I/O 操作成功、失败或取消时回调通知调用方
  6. 支持关联 I/O 操作与对应的处理程序
  7. 不同协议、不同的阻塞类型的连接都有不同的 Channel 类型与之对应,常用的 Channel 类型:
    • NioSocketChannel异步的客户端 TCP Socket 连接。
    • NioServerSocketChannel异步的服务器端 TCP Socket 连接。
    • NioDatagramChannel异步的 UDP 连接。
    • NioSctpChannel异步的客户端 Sctp 连接。
    • NioSctpServerChannel异步的 Sctp 服务器端连接,这些通道涵盖了 UDP 和 TCP 网络 IO 以及文件 IO。

3、Selector

  1. Netty 基于 Selector 对象实现 I/O 多路复用,通过 Selector 一个线程可以监听多个连接的 Channel 事件。
  2. 当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select) 这些注册的 Channel 是否有已就绪的 I/O 事件(例如可读,可写,网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个 Channel

4、ChannelHandler 及其实现类

  1. ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的下一个处理程序。
  2. ChannelHandler 本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间,可以继承它的子类

ChannelHandler 及其实现类一览图:

image-20210819233354647

  • ChannelInboundHandler:用于处理入站 I/O 事件。
  • ChannelOutboundHandler:用于处理出站 I/O 操作。

适配器模式:

  • ChannelInboundHandlerAdapter:用于处理入站 I/O 事件。
  • ChannelOutboundHandlerAdapter:用于处理出站 I/O 操作。
  • ChannelDuplexHandler:用于处理入站和出站事件。

为什么ChannelDuplexHandler既能解决入站事件,又能解决出站事件?

查看ChannelDuplexHandler的实现

1
public class ChannelDuplexHandler extends ChannelInboundHandlerAdapter implements ChannelOutboundHandler {...}
  • ChannelDuplexHandler继承了ChannelInboundHandlerAdapter类,所以能解决入站事件
  • ChannelDuplexHandler实现了ChannelOutboundHandler接口,所以能解决出站事件

我们经常需要自定义一个 Handler 类去继承 ChannelInboundHandlerAdapter,然后通过重写相应方法实现业务逻辑,我们接下来看看一般都需要重写哪些方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ChannelInboundHandlerAdapter extends ChannelHandlerAdapter implements ChannelInboundHandler {
public ChannelInboundHandlerAdapter() {}
public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelRegistered();
}
public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelUnregistered();
}
// 通道就绪事件
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelActive();
}
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelInactive();
}
// 通道读取数据事件
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ctx.fireChannelRead(msg);
}
// 数据读取完毕事件
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelReadComplete();
}
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
ctx.fireUserEventTriggered(evt);}
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelWritabilityChanged();
}
// 通道发生异常事件
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.fireExceptionCaught(cause);
}
}

ChannelInboundHandlerAdapter的所有实现方法:

image-20210819234347536

image-20210819234419147

5、Pipeline 和 ChannelPipeline

ChannelPipeline 是一个重点:

  1. ChannelPipeline 是一个 Handler 的集合,它负责处理和拦截 inbound 或者 outbound 的事件和操作,相当于一个贯穿 Netty 的链。(也可以这样理解:ChannelPipeline 是 保存 ChannelHandler 的 List,用于处理或拦截 Channel 的入站事件和出站操作)

  2. ChannelPipeline 实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个的 ChannelHandler 如何相互交互

  3. 在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应,它们的组成关系如下

    • image-20210819234704352

    • 一个 Channel 包含了一个 ChannelPipeline,而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler

      • 可以通过Channel拿到ChannelPipeline,也可以通过ChannelPipeline拿到Channel(双方都包含对方的引用)

      • ChannelHandlerContext实际上是一个接口,在双向链表当中的ChannelHandlerContext实际上是ChannelHandlerContext的实现类 DefaultChannelHandlerContext

        • public interface ChannelHandlerContext extends AttributeMap, ChannelInboundInvoker, ChannelOutboundInvoker {...}
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16

          - 入站事件和出站事件在一个双向链表中,入站事件会从链表 head 往后传递到最后一个入站的 handler,出站事件会从链表 tail 往前传递到最前一个出站的 handler,两种类型的 handler 互不干扰

          - ChannelPipeline提供了ChannelHandler链的容器。以==客户端==应用程序为例:
          - **如果事件的运动方向是从客户端到服务端的,那么我们称这些事件为出站的**,即客户端发送给服务端的数据会通过pipeline中的一系列ChannelOutboundHandler,并被这些Handler处理,以上图就是从链表 tail 往前传递到最前一个出站的 handler (head)
          - **如果事件的运动方向是从服务端到客户端的,那么我们称这些事件为入站的**,即服务端发送给客户端的数据会通过pipeline中的一系列ChannelInboundHandler,并被这些Handler处理,以上图就是从链表 head 往后传递到最后一个入站的 handler(tail)。
          - 前面客户端和服务端都是Inbound是因为他们都要读对方的消息,读取对方的消息就是入站

          4. 常用方法

          - ```java
          // 把一个业务处理类(handler)添加到链中的第一个位置
          ChannelPipeline addFirst(ChannelHandler... handlers);

          // 把一个业务处理类(handler)添加到链中的最后一个位置
          ChannelPipeline addLast(ChannelHandler... handlers);

6、ChannelHandlerContext

  1. 保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象

  2. 即ChannelHandlerContext 中包含一个具体的事件处理器 ChannelHandler , 同时ChannelHandlerContext 中也绑定了对应的 pipeline 和 Channel 的信息,方便对 ChannelHandler进行调用。

  3. 常用方法

    • // 关闭通道
      ChannelFuture close();
      
      // 刷新
      ChannelOutboundInvoker flush();
      
      // 将数据写到 ChannelPipeline 中当前ChannelHandler 的下一个 ChannelHandler 开始处理(出站)
      ChannelFuture writeAndFlush(Object msg);
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33



      ### 7、ChannelOption

      1. Netty 在创建 Channel 实例后,一般都需要设置 ChannelOption 参数。
      2. ChannelOption 参数如下:
      - `ChannelOption.SO_BACKLOG`:对应 TCP/IP 协议 listen 函数中的 backlog 参数,**用来初始化服务器可连接队列大小**。服务端处理客户端连接请求是顺序处理的,所以同一时间只能处理一个客户端连接。多个客户端来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理,backlog 参数指定了队列的大小。
      - `ChannelOption.SO_KEEPALIVE`:一直保持连接活动状态



      ### 8、EventLoopGroup 和其实现类 NioEventLoopGroup

      1. EventLoopGroup 是一组 EventLoop 的抽象,Netty 为了更好的利用多核 CPU 资源,一般会有多个 EventLoop 同时工作,每个 EventLoop 维护着一个 Selector 实例。

      2. EventLoopGroup 提供 next 接口,可以从组里面按照一定规则获取其中一个 EventLoop来处理任务。在 Netty ==服务器端==编程中,我们一般都需要提供两个 EventLoopGroup,例如:`BossEventLoopGroup` 和 `WorkerEventLoopGroup`。

      3. 通常一个服务端口,即一个 ServerSocketChannel 对应一个Selector 和一个EventLoop线程。BossEventLoop 负责接收客户端的连接并将 SocketChannel 交给 WorkerEventLoopGroup 来进行 IO 处理,如下图所示

      - ![image-20210820000928747](Netty/image-20210820000928747.png)
      - BossEventLoopGroup 通常是一个单线程的 EventLoop,EventLoop 维护着一个注册了ServerSocketChannel 的 Selector 实例,BossEventLoop 不断轮询 Selector 将连接事件分离出来
      - 通常是 `OP_ACCEPT` 事件,然后将接收到的 SocketChannel 交给 WorkerEventLoopGroup
      - WorkerEventLoopGroup 会由 next 选择其中一个 EventLoop来将这个 SocketChannel 注册到其维护的 Selector 并对其后续的 IO 事件进行处理

      4. 常用方法

      - ```java
      // 构造方法
      public NioEventLoopGroup();

      // 断开连接,关闭线程
      public Future<?> shutdownGracefully();

9、Unpooled 类

  1. Netty 提供一个专门用来操作缓冲区(即Netty的数据容器)的工具类

  2. 常用方法如下所示

    • //通过给定的数据和字符编码返回一个 ByteBuf 对象(类似于 NIO 中的 ByteBuffer 但有区别)
      public static ByteBuf copiedBuffer(CharSequence string, Charset charset)
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19

      ![image-20210820003558340](Netty/image-20210820003558340.png)

      ```java
      // 创建一个ByteBuf
      ByteBuf buffer = Unpooled.buffer(10);

      // 常用方法
      // 写入
      buffer.writeByte(i);

      // 读取
      buffer.readByte();

      // 根据下标读取
      buffer.getByte(i);

      // 获取buffer的长度
      buffer.capacity();

结合上图与代码对ByteBuf进行讲解:

  • 创建一个ByteBuf对象,该对象包含一个数组arr , 是一个byte[10]
  • 在netty 的ByteBuf中,不需要先nio中的ByteBuffer一样,使用flip 进行读写反转
    • 原因:netty 的ByteBuf在底层维护了两个变量:readerindexwriterIndex(双指针模式)。其中
      • readerindex:用于记录ByteBuf读时的位置
      • writerIndex:用于记录ByteBuf写时的位置
    • netty 的ByteBuf在底层还维护了一个重要的变量:capacity——用来保存ByteBuf的底层byte[]数组的长度
    • 通过这3个变量的合作,完成了netty的ByteBuf的读写相关操作
  • 通过 readerindex 和 writerIndex 和 capacity, 将buffer分成三个区域(从上图可以看出)
    • [0,readerindex):已经读取的区域
    • [readerindex,writerIndex):可读的区域
    • [writerIndex,capacity):可写的区域
  • 在ByteBuf读取的方法中,有着 getByte(int) 与 readByte()两个方法,两者的区别:
    • 对于readByte()方法:readerindex会随着readByte()方法的执行而增加
    • 对于getByte(int)方法:readerindex不会随着readByte()方法的执行而增加
  • 对于ByteBuf的写方法:writeByte()——writerIndex会随着writeByte()方法的执行而增加
  • 注意:
    • 如果使用了ByteBuf的readByte()方法进行读取的时候,由于readerindex会随着readByte()方法的执行而增加,所以在进行第二次读取的时候会发生数组下标越界异常,需要我们调用ByteBuf的readerIndex(int readIndex)方法重新设置读取位置。

也可以通过以下方法创建一个ByteBuf对象:

1
2
//创建ByteBuf
ByteBuf byteBuf = Unpooled.copiedBuffer("hello,world!", Charset.forName("utf-8"));

ByteBuf的一些API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 查看当前的ByteBuf是否有数组支撑
byteBuf.hasArray();

// 将当前ByteBuf转成char[]数组
byte[] content = byteBuf.array();

// 将 content 转成字符串
new String(content, Charset.forName("utf-8"));

// 获取ByteBuf的偏移量
byteBuf.arrayOffset(); // 0

// 获取ByteBuf的readerindex
byteBuf.readerIndex(); // 0

// 获取ByteBuf的writerIndex
byteBuf.writerIndex(); // 12

// 获取ByteBuf的容量
byteBuf.capacity(); // 36

// 获取ByteBuf的可读的字节数,根据readerIndex推出来的
// 如果在这之前调用了ByteBuf的readByte()方法,则byteBuf.readableBytes();返回的值为11
byteBuf.readableBytes(); // 12

// 按照某个范围读取,其中第一个参数:从哪里开始;第二个参数:读取的长度;第三参数:格式
byteBuf.getCharSequence(0, 4, Charset.forName("utf-8"));

注意:通过这种方式创建出来的bytebuf对象的底层实际上是UnpooledByteBufAllocator的内部类InstrumentedUnpooledUnsafeHeapByteBuf类型。

image-20210820035309447

10、Netty应用实例-群聊系统

实例要求:

  1. 编写一个 Netty 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
  2. 实现多人群聊
  3. 服务器端:可以监测用户上线,离线,并实现消息转发功能
  4. 客户端:通过channel 可以无阻塞发送消息给其它所有用户,同时可以接受其它用户发送的消息(有服务器转发得到)
  5. 目的:进一步理解Netty非阻塞网络编程机制
  6. 效果:
    • image-20210820001615735

代码:

服务器端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.awo.netty.groupchat;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class GroupChatServer {

private final int port;

public GroupChatServer(int port) {
this.port = port;
}

/**
* 编写run方法,处理客户端的请求
*/
public void run() {
//创建两个线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
ServerBootstrap serverBootstrap = new ServerBootstrap()
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG,128)
.childOption(ChannelOption.SO_KEEPALIVE,true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//获取到pipeline
ChannelPipeline pipeline = socketChannel.pipeline();
//向pipeline加入解码器
pipeline.addLast("decoder", new StringDecoder());
//向pipeline加入编码器
pipeline.addLast("encoder", new StringEncoder());
//加入自己的业务处理handler
pipeline.addLast(new GroupChatServerHandler());
}
});
System.out.println("netty 服务器启动");
ChannelFuture future = serverBootstrap.bind(port).sync();
//监听关闭
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}

public static void main(String[] args) {
new GroupChatServer(7777).run();
}
}

服务器端的handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package com.awo.netty.groupchat;

import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.util.concurrent.GlobalEventExecutor;

import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;

public class GroupChatServerHandler extends SimpleChannelInboundHandler<String> {

//定义一个channle 组,管理所有的channel
//GlobalEventExecutor.INSTANCE) 是全局的事件执行器,是一个单例
private static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

/**
* handlerAdded 表示连接建立,一旦连接,第一个被执行
* 将当前channel 加入到 channelGroup
* @param ctx
* @throws Exception
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
//将该客户加入聊天的信息推送给其它在线的客户端
/*
该方法会将 channelGroup 中所有的channel 遍历,并发送 消息,
我们不需要自己遍历
*/
channel.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 加入聊天" + " \n");
channelGroup.add(channel);
}

/**
* 断开连接, 将xx客户离开信息推送给当前在线的客户
* @param ctx
* @throws Exception
*/
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
channel.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 离开了" + " \n");
}

/**
* 表示channel 处于活动状态, 提示 xx上线
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
System.out.println("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 上线了~");
}

/**
* 表示channel 处于不活动状态, 提示 xx离线了
* @param ctx
* @throws Exception
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
System.out.println("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 离线了~");
}

/**
* 读取数据
* @param ctx
* @param msg
* @throws Exception
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
//获取到当前channel
Channel channel = ctx.channel();
//这时我们遍历channelGroup, 根据不同的情况,回送不同的消息
channelGroup.forEach(ch -> {
if (ch != channel) {
ch.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 发送了消息" + msg + "\n");
} else { //回显自己发送的消息给自己
channel.writeAndFlush("[自己 " + sdf.format(new Date()) + "]" + msg + "\n");
}
});
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//关闭通道
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.awo.netty.groupchat;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

import java.util.Scanner;

public class GroupChatClient {
//属性
private final String host;
private final int port;

public GroupChatClient(String host, int port) {
this.host = host;
this.port = port;
}

public void run() {
EventLoopGroup group = new NioEventLoopGroup();

try {
Bootstrap bootstrap = new Bootstrap()
.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//获取到pipeline
ChannelPipeline pipeline = socketChannel.pipeline();
//向pipeline加入解码器
pipeline.addLast("decoder", new StringDecoder());
//向pipeline加入编码器
pipeline.addLast("encoder", new StringEncoder());
//加入自己的业务处理handler
pipeline.addLast(new GroupChatClientHandler());
}
});

ChannelFuture channelFuture = bootstrap.connect(host, port).sync();
//得到channel
Channel channel = channelFuture.channel();
System.out.println("-------" + channel.localAddress()+ "--------");
//客户端需要输入信息,创建一个扫描器
Scanner sc = new Scanner(System.in);
while (sc.hasNextLine()) {
String msg = null;
msg = sc.nextLine();
//通过channel 发送到服务器端
channel.writeAndFlush(msg + "\r\n");
}
//channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
group.shutdownGracefully();
}
}

public static void main(String[] args) {
new GroupChatClient("localhost", 7777).run();
}
}

客户端的handler:

1
2
3
4
5
6
7
8
9
10
11
package com.awo.netty.groupchat;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

public class GroupChatClientHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, String msg) throws Exception {
System.out.println(msg.trim());
}
}

sync()是因为:本身bootstrap里的任务如:监听器等等是异步的。所以适用此方法等待异步方法处理完毕再完成启动

11、Netty心跳检测机制案例

实例要求:

  1. 编写一个 Netty心跳检测机制案例, 当服务器超过3秒没有读时,就提示读空闲
  2. 当服务器超过5秒没有写操作时,就提示写空闲
  3. 实现当服务器超过7秒没有读或者写操作时,就提示读写空闲

代码:

服务器端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.atguigu.netty.heartbeat;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.timeout.IdleStateHandler;

import java.util.concurrent.TimeUnit;

public class MyServer {
public static void main(String[] args) throws Exception{


//创建两个线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(); //8个NioEventLoop
try {

ServerBootstrap serverBootstrap = new ServerBootstrap();

serverBootstrap.group(bossGroup, workerGroup);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.handler(new LoggingHandler(LogLevel.INFO));
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {

@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
//加入一个netty 提供 IdleStateHandler
pipeline.addLast(new IdleStateHandler(7000,7000,10, TimeUnit.SECONDS));
//加入一个对空闲检测进一步处理的handler(自定义)
pipeline.addLast(new MyServerHandler());
}
});

//启动服务器
ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}

服务器的handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.atguigu.netty.heartbeat;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleStateEvent;

public class MyServerHandler extends ChannelInboundHandlerAdapter {

/**
*
* @param ctx 上下文
* @param evt 事件
* @throws Exception
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {

if(evt instanceof IdleStateEvent) {

//将 evt 向下转型 IdleStateEvent
IdleStateEvent event = (IdleStateEvent) evt;
String eventType = null;
switch (event.state()) {
case READER_IDLE:
eventType = "读空闲";
break;
case WRITER_IDLE:
eventType = "写空闲";
break;
case ALL_IDLE:
eventType = "读写空闲";
break;
}
System.out.println(ctx.channel().remoteAddress() + "--超时时间--" + eventType);
System.out.println("服务器做相应处理..");

//如果发生空闲,我们关闭通道
// ctx.channel().close();
}
}
}

对于以上代码的几点说明:

  • **handler(new LoggingHandler(LogLevel.INFO));**:这代码的作用是在bossGroup开启日志处理

  • IdleStateHandler类的相关说明:

    • IdleStateHandler 是netty 提供的处理空闲状态的处理器

    • 文档说明:==triggers an {@link IdleStateEvent} when a {@link Channel} has not performed read, write, or both operation for a while.==

    • public class IdleStateHandler extends ChannelDuplexHandler {
      
          public IdleStateHandler(long readerIdleTime, long writerIdleTime, long allIdleTime, TimeUnit unit) {
              this(false, readerIdleTime, writerIdleTime, allIdleTime, unit);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61

      - IdleStateHandler继承了ChannelDuplexHandler,说明了它既能处理入站事件,也能处理出站事件

      - IdleStateHandler的构造方法的参数说明:

      1. **long readerIdleTime**:表示多长时间没有读,就会发送一个心跳检测包检测是否连接
      2. **long writerIdleTime**:表示多长时间没有写,就会发送一个心跳检测包检测是否连接
      3. **long allIdleTime**:表示多长时间没有读写,就会发送一个心跳检测包检测是否连接
      4. **TimeUnit unit**:时间单位

      - 当 IdleStateEvent 触发后,就会传递给管道 的下一个handler去处理
      * 通过调用(触发)下一个handler 的 `userEventTiggered`方法,在该方法中去处理 IdleStateEvent(读空闲,写空闲,读写空闲)



      ### 12、Netty 通过WebSocket编程实现服务器和客户端长连接

      实例要求:

      1. Http协议是无状态的, 浏览器和服务器间的请求响应一次,下一次会重新创建连接.
      2. 要求:实现基于webSocket的长连接的全双工的交互
      3. 改变Http协议多次请求的约束,实现长连接了, 服务器可以发送消息给浏览器
      4. 客户端浏览器和服务器端会相互感知,比如服务器关闭了,浏览器会感知,同样浏览器关闭了,服务器会感知
      5. 效果:
      - ![image-20210820001859808](Netty/image-20210820001859808.png)

      代码:

      服务器端:(ChannelInitializer<SocketChannel>当中的内容,其他与上面类似)

      ```java
      serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {

      @Override
      protected void initChannel(SocketChannel ch) throws Exception {
      ChannelPipeline pipeline = ch.pipeline();

      //因为基于http协议,使用http的编码和解码器
      pipeline.addLast(new HttpServerCodec());
      //是以块方式写,添加ChunkedWriteHandler处理器
      pipeline.addLast(new ChunkedWriteHandler());

      /*
      说明
      1. http数据在传输过程中是分段, HttpObjectAggregator ,就是可以将多个段聚合
      2. 这就就是为什么,当浏览器发送大量数据时,就会发出多次http请求
      */
      pipeline.addLast(new HttpObjectAggregator(8192));
      /*
      说明
      1. 对应websocket ,它的数据是以 帧(frame) 形式传递
      2. 可以看到WebSocketFrame 下面有六个子类
      3. 浏览器请求时 ws://localhost:7000/hello 表示请求的uri
      4. WebSocketServerProtocolHandler 核心功能是将 http协议升级为 ws协议 , 保持长连接
      */
      pipeline.addLast(new WebSocketServerProtocolHandler("/hello2"));

      //自定义的handler ,处理业务逻辑
      pipeline.addLast(new MyTextWebSocketFrameHandler());
      }
      });

服务器端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.atguigu.netty.websocket;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;

import java.time.LocalDateTime;

//这里 TextWebSocketFrame 类型,表示一个文本帧(frame)
public class MyTextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame>{
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {

System.out.println("服务器收到消息 " + msg.text());

//回复消息
ctx.channel().writeAndFlush(new TextWebSocketFrame("服务器时间" + LocalDateTime.now() + " " + msg.text()));
}

//当web客户端连接后, 触发方法
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
//id 表示唯一的值,LongText 是唯一的 ShortText 不是唯一
System.out.println("handlerAdded 被调用" + ctx.channel().id().asLongText());
System.out.println("handlerAdded 被调用" + ctx.channel().id().asShortText());
}


@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {

System.out.println("handlerRemoved 被调用" + ctx.channel().id().asLongText());
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("异常发生 " + cause.getMessage());
ctx.close(); //关闭连接
}
}

对以上代码的几点说明:

  • 由于建立的是webSocket长连接,http为短连接,需要将http协议升级为ws协议
  • 对于http:
    • http数据在传输过程中是分段传输,所以需要添加HttpObjectAggregator ,可以将多个段的数据进行聚合
    • 这就就是为什么,当浏览器发送大量数据时,就会发出多次http请求
  • 对于webSocket:
    • webSocket的数据是以 帧(frame) 形式传递,所以在进行数据处理的时候都是以帧为单位进行处理的
    • 对于ws协议:
      • 浏览器请求时 ws://localhost:7000/xxx:表示请求的uri
      • http协议升级为ws协议的方法:是通过一个 状态码 101
        • image-20210821000047461
    • netty与ws协议的一些方法:
      • webSocket数据对应类:WebSocketFrame,其下有六个子类,分别应用在不同的场景
        • image-20210820233534694
      • WebSocketServerProtocolHandler:核心功能是将 http协议升级为 ws协议,保持长连接

8、Google Protobuf

1、编码和解码的基本介绍

  1. 编写网络应用程序时,因为数据在网络中传输的都是二进制字节码数据,在发送数据时就需要编码,接收数据时就需要解码
    • image-20210821000350763
  2. codec(编解码器) 的组成部分有两个:decoder(解码器)encoder(编码器)
    • encoder:负责把业务数据转换成字节码数据
    • decoder:负责把字节码数据转换成业务数据

2、Netty 本身的编码解码的机制和问题分析

  1. Netty 自身提供了一些 codec(编解码器)
  2. Netty 提供的编码器encoder
    • StringEncoder:对字符串数据进行编码
    • ObjectEncoder:对 Java 对象进行编码
    • ……
  3. Netty 提供的解码器decoder
    • StringDecoder:对字符串数据进行解码
    • ObjectDecoder:对 Java 对象进行解码
    • ……
  4. Netty 本身自带的 ObjectDecoder 和 ObjectEncoder 可以用来实现 POJO 对象或各种业务对象的编码和解码,底层使用的仍是 Java 序列化技术,而Java 序列化技术本身效率就不高,存在如下问题:
    • 无法跨语言
    • 序列化后的体积太大,是二进制编码的 5 倍多。
    • 序列化性能太低
    • => 引出 新的解决方案 [Google 的 Protobuf]

3、Protobuf

1、Protobuf基本介绍和使用示意图

  1. Protobuf 是 Google 发布的开源项目,全称 Google Protocol Buffers,是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC[远程过程调用 remote procedure call ] 数据交换格式 。
    目前很多公司将http+json 替换成 tcp+protobuf
  2. 参考文档 :语言指南
  3. Protobuf 是以 message 的方式来管理数据的
  4. 支持跨平台跨语言,即[客户端和服务器端可以是不同的语言编写的] (支持目前绝大多数语言,例如 C++、C#、Java、python 等)
  5. 高性能,高可靠性
  6. 使用 protobuf 编译器能自动生成代码,Protobuf 是将类的定义使用.proto 文件进行描述
    • 说明,在idea 中编写 .proto 文件时,会自动提示是否下载 .ptotot 编写插件. 可以让语法高亮
  7. 然后通过 protoc.exe 编译器根据.proto 自动生成.java 文件
  8. protobuf 使用示意图:
    • image-20210821023654601

2、Netty中Protobuf的使用流程

  1. 在Maven 项目中引入 Protobuf 坐标,下载相关的jar包

    • <dependencies>
          <dependency>
              <groupId>com.google.protobuf</groupId>
              <artifactId>protobuf-java</artifactId>
              <version>3.6.1</version>
          </dependency>
      </dependencies>
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      2. 在IDEA创建.proto文件,进行.proto文件的编写(以Student.proto为例)

      - ```protobuf
      syntax = "proto3"; //版本
      option java_outer_classname = "StudentPOJO";//生成的外部类名,同时也是文件名
      //protobuf 使用message 管理数据
      message Student { //会在 StudentPOJO 外部类生成一个内部类 Student, 他是真正发送的POJO对象
      int32 id = 1; // Student 类中有一个属性 名字为 id 类型为int32(protobuf类型) 1表示属性序号,不是值
      string name = 2;
      }
  2. 利用protoc.exe 编译器对刚刚编写好的.proto文件进行编译,生成一个java文件

    • 执行指令(cmd)

      • protoc.exe --java_out=. Student.proto
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10

        - idea里面也可以下载相应的maven插件进行编译:有工具mave protobuf-java-util

        4. 之后会生成一个Student.java文件

        - 这里主要是看两点:

        - ```java
        // DO NOT EDIT!
        public static final class Student extends com.google.protobuf.GeneratedMessageV3 implements // 说明真正的PoJo 类是Student
  3. 把生成的 StudentPoJo.java 拷贝到自己的项目中打开

  4. 在项目的服务端ChannelInitializer<SocketChannel>中的initChannel方法里面添加解码的handler(服务端<—>解码),在解码的handler中添加StudentPOJO.Student.getDefaultInstance()

    • serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {//创建一个通道初始化对象(匿名对象)
          //给pipeline 设置处理器
          @Override
          protected void initChannel(SocketChannel ch) throws Exception {
              ChannelPipeline pipeline = ch.pipeline();
              //在pipeline加入ProtoBufDecoder
              //指定对哪种对象进行解码
              pipeline.addLast("decoder", new ProtobufDecoder(StudentPOJO.Student.getDefaultInstance()));
              pipeline.addLast(new NettyServerHandler());
          }
      }); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      7. 在项目的客户端`ChannelInitializer<SocketChannel>`中的`initChannel`方法里面添加编码的handler(客户端<--->编码)

      - ```java
      bootstrap.handler(new ChannelInitializer<SocketChannel>() {
      @Override
      protected void initChannel(SocketChannel ch) throws Exception {
      ChannelPipeline pipeline = ch.pipeline();
      //在pipeline中加入 ProtoBufEncoder
      pipeline.addLast("encoder", new ProtobufEncoder());
      pipeline.addLast(new NettyClientHandler()); //加入自己的处理器
      }
      });
  5. 在服务端的自定义handler中可以选择继承SimpleChannelInboundHandler并设置泛型StudentPOJO.Student,这样一来重写的channelRead0方法的第二个参数就变成了StudentPOJO.Student msg(而不是Object,还需要我们去判断Object类型向下转型),我可以通过该msg获取Student的相关信息

  6. 而在客户端就需要我们去生成一个StudentPOJO.Student,往StudentPOJO.Student设置一些信息:

    • StudentPOJO.Student student = StudentPOJO.Student.newBuilder().setId(4).setName("智多星 吴用").build();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55



      #### 3、使用Protobuf的几点说明

      1. Protobuf是以`message`的方式来管理数据的

      2. 在.proto文件的编写中使用message声明的变量,在之后生成java文件后会成为java文件的内部类,也是真正存储PoJo 类信息的地方,使用option java_outer_classname方式生成的类对象其实是java的外部类,包裹着存储PoJo 类信息的内部类

      3. java PoJo 类的属性数据类型 与 Protobuf文件中的属性数据类型的对比:

      ![image-20210821025959285](Netty/image-20210821025959285.png)

      4. 在Protobuf文件中 `int32 id = 1`中的`1`并不是属性的值,而是该属性在Protobuf文件的属性序号,即该属性是Protobuf文件的第几个属性(**从1开始**)

      5. 通过以上的项目的服务端与客户端可以发现一个问题:项目的handler与PoJo的耦合很高。基本上一个handler只能为一个PoJo服务

      6. 解决方法:**Protobuf可以使用 message 管理其他的message**

      7. Protobuf文件中可以使用一个总的message作为大包裹,里面包含了各式各样的PoJo信息——使用枚举的方式(注意:**在proto3 要求enum的编号从0开始**)

      - ```protobuf
      syntax = "proto3";
      option optimize_for = SPEED; // 加快解析
      option java_package="com.atguigu.netty.codec2"; //指定生成到哪个包下
      option java_outer_classname="MyDataInfo"; // 外部类名, 文件名

      //protobuf 可以使用message 管理其他的message
      message MyMessage {

      //定义一个枚举类型
      enum DataType {
      StudentType = 0; //在proto3 要求enum的编号从0开始
      WorkerType = 1;
      }

      //用data_type 来标识传的是哪一个枚举类型
      DataType data_type = 1;

      //表示每次枚举类型最多只能出现其中的一个, 节省空间
      oneof dataBody {
      Student student = 2;
      Worker worker = 3;
      }

      }

      message Student {
      int32 id = 1;//Student类的属性
      string name = 2; //
      }
      message Worker {
      string name=1;
      int32 age=2;
      }
  7. 这样的话,在服务端的ChannelInitializer<SocketChannel>中的initChannel方法的里面ProtobufDecoder的里面就不能写某个PoJo的getDefaultInstance(),而是得写整个大包裹的getDefaultInstance()

    • pipeline.addLast("decoder", new ProtobufDecoder(MyDataInfo.MyMessage.getDefaultInstance()));
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10

      9. 各种PoJo的信息的设置与获取在各自的handler中

      - 设置(客户端)

      - ```java
      MyDataInfo.MyMessage myMessage = null;
      myMessage = MyDataInfo.MyMessage.newBuilder().setDataType(MyDataInfo.MyMessage.DataType.StudentType).setStudent(MyDataInfo.Student.newBuilder().setId(5).setName("玉麒麟 卢俊义").build()).build();

      myMessage = MyDataInfo.MyMessage.newBuilder().setDataType(MyDataInfo.MyMessage.DataType.WorkerType).setWorker(MyDataInfo.Worker.newBuilder().setAge(20).setName("老李").build()).build();
    • 获取(服务端)(继承的SimpleChannelInboundHandler的泛型要修改成大包裹:MyDataInfo.MyMessage

      • //根据dataType 来显示不同的信息
        MyDataInfo.MyMessage.DataType dataType = msg.getDataType();
        if(dataType == MyDataInfo.MyMessage.DataType.StudentType) {
            MyDataInfo.Student student = msg.getStudent();
            System.out.println("学生id=" + student.getId() + " 学生名字=" + student.getName());
        
        } else if(dataType == MyDataInfo.MyMessage.DataType.WorkerType) {
            MyDataInfo.Worker worker = msg.getWorker();
            System.out.println("工人的名字=" + worker.getName() + " 年龄=" + worker.getAge());
        } else {
            System.out.println("传输的类型不正确");
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46

        -----





        ## 9、Netty编解码器和handler的调用机制

        ### 1、基本说明

        1. netty的组件设计:Netty的主要组件有Channel、EventLoop、ChannelFuture、ChannelHandler、ChannelPipe等
        2. **ChannelHandler充当了处理入站和出站数据的应用程序逻辑的容器**。例如,实现ChannelInboundHandler接口(或ChannelInboundHandlerAdapter),你就可以接收入站事件和数据,这些数据会被业务逻辑处理。当要给客户端发送响应时,也可以从ChannelInboundHandler冲刷数据。业务逻辑通常写在一个或者多个ChannelInboundHandler中。ChannelOutboundHandler原理一样,只不过它是用来处理出站数据的。
        3. **ChannelPipeline提供了ChannelHandler链的容器**。**以客户端应用程序为例,如果事件的运动方向是从客户端到服务端的,那么我们称这些事件为出站的,即客户端发送给服务端的数据会通过pipeline中的一系列ChannelOutboundHandler,并被这些Handler处理**,反之则称为入站的

        ![image-20210821034142241](Netty/image-20210821034142241.png)



        ### 2、编码解码器

        1. **当Netty发送或者接受一个消息的时候,就将会发生一次数据转换。==入站消息会被解码==:从字节转换为另一种格式(比如java对象);如果是==出站消息,它会被编码成字节==**。
        2. **Netty提供一系列实用的编解码器,他们都实现了ChannelInboundHadnler或者ChannelOutboundHandler接口。在这些类中,channelRead方法已经被重写了**。以入站为例,对于每个从入站Channel读取的消息,这个方法会被调用。随后,它将调用由解码器所提供的decode()方法进行解码,并将已经解码的字节转发给ChannelPipeline中的下一个ChannelInboundHandler。



        ### 3、解码器——ByteToMessageDecoder(服务器端,入站)

        1. 关系继承图

        - ![image-20210821034417112](Netty/image-20210821034417112.png)

        2. **由于不可能知道远程节点是否会一次性发送一个完整的信息,==tcp有可能出现粘包拆包的问题==**,这个类会对入站数据进行缓冲,直到它准备好被处理。

        3. 一个关于ByteToMessageDecoder实例分析

        - ```java
        public class ToIntegerDecoder extends ByteToMessageDecoder {
        // 读取一个int类型
        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        if (in.readableBytes() >= 4) {
        out.add(in.readInt());
        }
        }
        }
    • 说明:

      • 这个例子,每次入站从ByteBuf中读取4字节,将其解码为一个int,然后将它添加到下一个List中。当没有更多元素可以被添加到该List中时,它的内容将会被发送给下一个ChannelInboundHandler。int在被添加到List中时,会被自动装箱为Integer
      • 在调用readInt()方法前必须验证所输入的ByteBuf是否具有足够的数据。
      • 关于if还是while的问题,”decode”方法确实会被循环调用,只要还有可读就会一直循环,除非”decode”没有再读出数据,则会退出循环。
      • 所以如果把”if”改成”while”也是可以的没有区别,相当于自己先把buf处理完了,外层循环就不会再调用了
      • decode 会根据接收的数据,被调用多次,直到确定没有新的元素被添加到list,或者是ByteBuf 没有更多的可读字节为止
      • 如果list out 不为空,就会将list的内容传递给下一个 channelinboundhandler处理,该channelinboundhandler的方法也会被调用多次(不管是if还是while,因为循环调用的依据是list的内容)

      image-20210821035333607

Netty的handler链的调用机制

实例要求:使用自定义的编码器和解码器来说明Netty的handler 调用机制

  • 客户端发送long -> 服务器
  • 服务端发送long -> 客户端

思路:

image-20210821050856155

注意:

  • ctx.write 会去调用outbound的方法
  • outbound一定要放到最后一个inbound之前,保证inbound在write的时候,可以往前找到outbound

代码:

自定义编码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

public class MyLongToByteEncoder extends MessageToByteEncoder<Long> {
//编码方法
@Override
protected void encode(ChannelHandlerContext ctx, Long msg, ByteBuf out) throws Exception {

System.out.println("MyLongToByteEncoder encode 被调用");
System.out.println("msg=" + msg);
out.writeLong(msg);

}
}

自定义解码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;

import java.util.List;

public class MyByteToLongDecoder extends ByteToMessageDecoder {
/**
*
* decode 会根据接收的数据,被调用多次, 直到确定没有新的元素被添加到list
* , 或者是ByteBuf 没有更多的可读字节为止
* 如果list out 不为空,就会将list的内容传递给下一个 channelinboundhandler处理, 该处理器的方法也会被调用多次
*
* @param ctx 上下文对象
* @param in 入站的 ByteBuf
* @param out List 集合,将解码后的数据传给下一个handler
* @throws Exception
*/
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {

System.out.println("MyByteToLongDecoder 被调用");
//因为 long 8个字节, 需要判断有8个字节,才能读取一个long
if(in.readableBytes() >= 8) {
out.add(in.readLong());
}
}
}

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}

服务端的处理器的初始化:MyServerInitializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyServerInitializer extends ChannelInitializer<SocketChannel> {

@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();//一会下断点

//入站的handler进行解码 MyByteToLongDecoder
pipeline.addLast(new MyByteToLongDecoder());
//出站的handler进行编码
pipeline.addLast(new MyLongToByteEncoder());
//自定义的handler 处理业务逻辑
pipeline.addLast(new MyServerHandler());
System.out.println("xx");
}
}

服务端的自定义处理器:MyServerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

public class MyServerHandler extends SimpleChannelInboundHandler<Long> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Long msg) throws Exception {

System.out.println("从客户端" + ctx.channel().remoteAddress() + " 读取到long " + msg);

//给客户端发送一个long
ctx.writeAndFlush(98765L);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

public class MyClient {
public static void main(String[] args) throws Exception{

EventLoopGroup group = new NioEventLoopGroup();

try {

Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group).channel(NioSocketChannel.class)
.handler(new MyClientInitializer()); //自定义一个初始化类

ChannelFuture channelFuture = bootstrap.connect("localhost", 7000).sync();

channelFuture.channel().closeFuture().sync();

}finally {
group.shutdownGracefully();
}
}
}

客户端的处理器的初始化:MyClientInitializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyClientInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {

ChannelPipeline pipeline = ch.pipeline();

//加入一个出站的handler 对数据进行一个编码
pipeline.addLast(new MyLongToByteEncoder());

//这时一个入站的解码器(入站handler )
pipeline.addLast(new MyByteToLongDecoder());
//加入一个自定义的handler , 处理业务
pipeline.addLast(new MyClientHandler());

}
}

客户端的自定义处理器:MyServerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.CharsetUtil;

import java.nio.charset.Charset;

public class MyClientHandler extends SimpleChannelInboundHandler<Long> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Long msg) throws Exception {

System.out.println("服务器的ip=" + ctx.channel().remoteAddress());
System.out.println("收到服务器消息=" + msg);

}

//重写channelActive 发送数据
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("MyClientHandler 发送数据");
ctx.writeAndFlush(123456L); //发送的是一个long

//分析
//1. "abcdabcdabcdabcd" 是 16个字节
//2. 该处理器的前一个handler 是 MyLongToByteEncoder
//3. MyLongToByteEncoder 父类 MessageToByteEncoder
//4. 父类 MessageToByteEncoder
/*

public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
ByteBuf buf = null;
try {
if (acceptOutboundMessage(msg)) { //判断当前msg 是不是应该处理的类型,如果是就处理,不是就跳过encode
@SuppressWarnings("unchecked")
I cast = (I) msg;
buf = allocateBuffer(ctx, cast, preferDirect);
try {
encode(ctx, cast, buf);
} finally {
ReferenceCountUtil.release(cast);
}

if (buf.isReadable()) {
ctx.write(buf, promise);
} else {
buf.release();
ctx.write(Unpooled.EMPTY_BUFFER, promise);
}
buf = null;
} else {
ctx.write(msg, promise);
}
}
4. 因此我们编写 Encoder 是要注意传入的数据类型和处理的数据类型一致
*/
// ctx.writeAndFlush(Unpooled.copiedBuffer("abcdabcdabcdabcd",CharsetUtil.UTF_8));
}
}

执行流程:

image-20210821035744141

结论:

  • 不论解码器handler 还是 编码器handler 即接收的消息类型必须与待处理的消息类型一致,否则该handler不会被执行

    • 底层调用了父类 MessageToByteEncoder的write方法,源码:

    • public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
          ByteBuf buf = null;
          try {
              //判断当前msg 是不是应该处理的类型,如果是就处理,不是就跳过encode
              if (acceptOutboundMessage(msg)) { 
                  @SuppressWarnings("unchecked")
                  I cast = (I) msg;
                  buf = allocateBuffer(ctx, cast, preferDirect);
                  try {
                      encode(ctx, cast, buf);
                  } finally {
                      ReferenceCountUtil.release(cast);
                  }
      
                  if (buf.isReadable()) {
                      ctx.write(buf, promise);
                  } else {
                      buf.release();
                      ctx.write(Unpooled.EMPTY_BUFFER, promise);
                  }
                  buf = null;
              } else {
                  ctx.write(msg, promise);
              }
          }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - **在解码器 进行数据解码时,需要判断 缓存区(ByteBuf)的数据是否足够 ,否则接收到的结果会期望结果可能不一致**



      ### 4、解码器——ReplayingDecoder(客户端,出站)

      1. ```java
      public abstract class ReplayingDecoder<S> extends ByteToMessageDecoder
  1. ReplayingDecoder扩展了ByteToMessageDecoder类,使用这个类,我们不必调用readableBytes()方法。参数S指定了用户状态管理的类型,其中Void代表不需要状态管理

  2. ReplayingDecoder使用方便,但它也有一些局限性:

    • 并不是所有的 ByteBuf 操作都被支持,如果调用了一个不被支持的方法,将会抛出一个 UnsupportedOperationException
    • ReplayingDecoder 在某些情况下可能稍慢于 ByteToMessageDecoder,例如网络缓慢并且消息格式复杂时,消息会被拆成了多个碎片,速度变慢

5、其它编解码器

  • 其它解码器:
    1. LineBasedFrameDecoder:这个类在Netty内部也有使用,它使用行尾控制字符(\n或者\r\n)作为分隔符来解析数据
    2. DelimiterBasedFrameDecoder使用自定义的特殊字符作为消息的分隔符
    3. HttpObjectDecoder:一个HTTP数据的解码器
    4. LengthFieldBasedFrameDecoder通过指定长度来标识整包消息,这样就可以自动的处理==黏包==和==半包==消息
    5. image-20210821053430472
    6. image-20210821053547439
  • 其它编码器:
    1. image-20210821053839116
    2. image-20210821053849720
  • 例子:如果客户端传输大量数据到服务端的时候,为了节省时间与开销。可以在客户端使用ZlibEncoder对数据进行压缩编码,然后在服务端使用ZlibDecoder进行压缩阶码就能得到数据。这些操作Netty都帮助我们完成了,我们只需要在将Initializer对象的initChennel方法中将对应的编解码器加入pipeline当中

6、Log4j 整合到Netty

  1. 在Maven 中添加对Log4j的依赖 在 pom.xml

    • <dependency>
          <groupId>log4j</groupId>
          <artifactId>log4j</artifactId>
          <version>1.2.17</version>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-api</artifactId>
          <version>1.7.25</version>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-log4j12</artifactId>
          <version>1.7.25</version>
          <scope>test</scope>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-simple</artifactId>
          <version>1.7.25</version>
          <scope>test</scope>
      </dependency>
      
      1
      2
      3
      4
      5
      6
      7
      8

      2. 配置 Log4j,在 resources/log4j.properties

      - ```properties
      log4j.rootLogger=DEBUG, stdout
      log4j.appender.stdout=org.apache.log4j.ConsoleAppender
      log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
      log4j.appender.stdout.layout.ConversionPattern=[%p] %C{1} - %m%n
  2. 演示:

    • image-20210821054850668

10、TCP 粘包和拆包 及解决方案

1、TCP 粘包和拆包基本介绍

  1. TCP是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发给接收端的包,更有效的发给对方,使用了优化方法(==Nagle算法==),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样做虽然提高了效率,但是接收端就难于分辨出完整的数据包了,因为面向流的通信是无消息保护边界
  2. 由于TCP无消息保护边界,需要在接收端处理消息边界问题,也就是我们所说的粘包、拆包问题,看一张图:
    • image-20210821155622581
  3. 客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下四种情况:
    1. 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包
    2. 服务端一次接受到了两个数据包,D1和D2粘合在一起,称之为==TCP粘包==
    3. 服务端分两次读取到了数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这称之为==TCP拆包==
    4. 服务端分两次读取到了数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余部分内容D1_2和完整的D2包。

2、TCP 粘包和拆包解决方案

  1. 使用自定义协议 + 编解码器 来解决
  2. 关键就是要解决 服务器端每次读取数据长度的问题,这个问题解决,就不会出现服务器多读或少读数据的问题,从而避免的TCP 粘包、拆包 。

看一个具体的实例:

  1. 要求客户端发送 5 个 Message 对象,客户端每次发送一个 Message 对象
  2. 服务器端每次接收一个Message,分5次进行解码, 每读取到 一个Message,会回复一个Message 对象 给客户端。

image-20210821155936176

代码:

由于我们是自定义协议,需要我们编写一个协议包类,规定每次发送的协议包的内容和大小(重要)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//协议包
public class MessageProtocol {
private int len; //关键
private byte[] content;

public int getLen() {
return len;
}

public void setLen(int len) {
this.len = len;
}

public byte[] getContent() {
return content;
}

public void setContent(byte[] content) {
this.content = content;
}
}

由于我们是自定义协议,需要我们自定义编解码器,将我们自定义的协议包进行编解码

编码器:

1
2
3
4
5
6
7
8
9
10
11
12
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

public class MyMessageEncoder extends MessageToByteEncoder<MessageProtocol> {
@Override
protected void encode(ChannelHandlerContext ctx, MessageProtocol msg, ByteBuf out) throws Exception {
System.out.println("MyMessageEncoder encode 方法被调用");
out.writeInt(msg.getLen());
out.writeBytes(msg.getContent());
}
}

解码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ReplayingDecoder;

import java.util.List;

public class MyMessageDecoder extends ReplayingDecoder<Void> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
System.out.println("MyMessageDecoder decode 被调用");
//需要将得到二进制字节码-> MessageProtocol 数据包(对象)
int length = in.readInt();

byte[] content = new byte[length];
in.readBytes(content);

//封装成 MessageProtocol 对象,放入 out, 传递下一个handler业务处理
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(length);
messageProtocol.setContent(content);
// 将协议包放入list当中
out.add(messageProtocol);

}
}

服务器端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

import java.nio.charset.Charset;
import java.util.UUID;


//处理业务的handler
public class MyServerHandler extends SimpleChannelInboundHandler<MessageProtocol>{
private int count;

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//cause.printStackTrace();
ctx.close();
}

@Override
protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception {

//接收到数据,并处理
int len = msg.getLen();
byte[] content = msg.getContent();

System.out.println();
System.out.println();
System.out.println();
System.out.println("服务器接收到信息如下");
System.out.println("长度=" + len);
System.out.println("内容=" + new String(content, Charset.forName("utf-8")));

System.out.println("服务器接收到消息包数量=" + (++this.count));

//回复消息
String responseContent = UUID.randomUUID().toString();
int responseLen = responseContent.getBytes("utf-8").length;
byte[] responseContent2 = responseContent.getBytes("utf-8");
//构建一个协议包
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(responseLen);
messageProtocol.setContent(responseContent2);
ctx.writeAndFlush(messageProtocol);
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

public class MyClient {
public static void main(String[] args) throws Exception{

EventLoopGroup group = new NioEventLoopGroup();

try {

Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group).channel(NioSocketChannel.class)
.handler(new MyClientInitializer()); //自定义一个初始化类

ChannelFuture channelFuture = bootstrap.connect("localhost", 7000).sync();

channelFuture.channel().closeFuture().sync();

}finally {
group.shutdownGracefully();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyClientInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {

ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new MyMessageEncoder()); //加入编码器
pipeline.addLast(new MyMessageDecoder()); //加入解码器
pipeline.addLast(new MyClientHandler());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

import java.nio.charset.Charset;

public class MyClientHandler extends SimpleChannelInboundHandler<MessageProtocol> {

private int count;
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
//使用客户端发送10条数据 "今天天气冷,吃火锅" 编号

for(int i = 0; i< 5; i++) {
String mes = "今天天气冷,吃火锅";
byte[] content = mes.getBytes(Charset.forName("utf-8"));
int length = mes.getBytes(Charset.forName("utf-8")).length;

//创建协议包对象
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(length);
messageProtocol.setContent(content);
ctx.writeAndFlush(messageProtocol);

}

}

// @Override
protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception {

int len = msg.getLen();
byte[] content = msg.getContent();

System.out.println("客户端接收到消息如下");
System.out.println("长度=" + len);
System.out.println("内容=" + new String(content, Charset.forName("utf-8")));

System.out.println("客户端接收消息数量=" + (++this.count));

}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("异常消息=" + cause.getMessage());
ctx.close();
}
}

效果:

客户端:

image-20210821160639783

服务端:

image-20210821160731328

调用流程说明:

  1. 客户端发送5条数据到服务端(编码5次)
  2. 服务端调用解码器将客户端发送过来的数据进行解码,收到信息后,发送一个协议包给客户端(编码1次),由于客户端发送了5条数据,所以这个过程会执行5次
  3. 客户端接收到服务端发送过来的协议包,调用解码器进行解码,接收数据。由于服务端会回发5次数据,所以客户端也会接收到5次数据,每一次接收都要调用一次解码器进行数据解码
  4. 由于数据都是通过自定义的协议包进行传输的,协议包中规定了每一次传输的数据的长度,所以不会出现TCP的粘包拆包问题。

11、Netty 核心源码剖析

1、Netty 启动过程源码剖析

说明:

  1. 源码需要剖析到Netty 调用doBind方法, 追踪到 NioServerSocketChannel的doBind
  2. 并且要Debug 程序到 NioEventLoop类 的run代码 ,无限循环,在服务器端运行。

image-20210823041445976

Netty启动过程梳理:

  1. 创建2个 EventLoopGroup 线程池数组。数组默认大小CPU*2,方便chooser选择线程池时提高性能
  2. BootStrap 将 boss 设置为 group属性,将 worker 设置为 childer 属性
  3. 通过 bind 方法启动,内部重要方法为 initAndRegisterdobind 方法
  4. initAndRegister 方法会反射创建 NioServerSocketChannel 及其相关的 NIO 的对象, pipeline , unsafe,同时也为 pipeline 初始了 head 节点和 tail 节点。
  5. register0 方法成功以后调用在 dobind 方法中调用 doBind0 方法,该方法会 调用 NioServerSocketChannel 的 doBind 方法对 JDK 的 channel 和端口进行绑定,完成 Netty 服务器的所有启动,并开始监听连接事件

2、Netty 接受请求过程源码剖析

说明:

  1. 从之前服务器启动的源码中,我们得知,服务器最终注册了一个 Accept 事件等待客户端的连接。我们也知道,NioServerSocketChannel 将自己注册到了 boss 单例线程池(reactor 线程)上,也就是 EventLoop 。
  2. 先简单说下EventLoop的逻辑(后面我们详细讲解EventLoop)
    • EventLoop 的作用是一个死循环,而这个循环中做3件事情:
      1. 有条件的等待 Nio 事件。
      2. 处理 Nio 事件。
      3. 处理消息队列中的任务。
  3. 仍用前面的项目来分析:进入到 NioEventLoop 源码中后,在private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) 方法开始调试
  4. 最终我们要分析到AbstractNioChannel 的 doBeginRead 方法, 当到这个方法时,针对于这个客户端的连接就完成了,接下来就可以监听读事件了

Netty接受请求过程梳理:

总体流程

接受连接 —–> 创建一个新的NioSocketChannel ———–> 注册到一个 worker EventLoop 上 ——–> 注册selecot Read 事件。

  1. 服务器轮询 Accept 事件,获取事件后调用 unsafe 的 read 方法,这个 unsafe 是 ServerSocket 的内部类,该方法内部由2部分组成
  2. doReadMessages 用于创建 NioSocketChannel 对象,该对象包装 JDK 的 Nio Channel 客户端。该方法会像创建 ServerSocketChanel 类似创建相关的 pipeline , unsafe,config
  3. 随后执行 pipeline.fireChannelRead 方法,并将自己绑定到一个 chooser 选择器选择的 workerGroup 中的一个 EventLoop。并且注册一个0,表示注册成功,但并没有注册读(1)事件

3、Pipeline Handler HandlerContext创建源码剖析

  1. 每当创建 ChannelSocket 的时候都会创建一个绑定的 pipeline,一对一的关系,创建 pipeline 的时候也会创建 tail 节点和 head 节点,形成最初的链表
  2. 在调用 pipeline 的 addLast 方法的时候,会根据给定的 handler 创建一个 Context,然后将这个 Context 插入到链表的尾端(tail 前面)
  3. Context 包装 handler,多个 Context 在 pipeline 中形成了双向链表
  4. 入站方向叫 inbound,由 head 节点开始,出站方法叫 outbound ,由 tail 节点开始

4、ChannelPipeline 调度 handler 的源码剖析

  1. 当一个请求进来的时候,ChannelPipeline 是如何调用内部的这些 handler 的呢?
  2. 首先,当一个请求进来的时候,会第一个调用 pipeline 的 相关方法,如果是入站事件,这些方法由 fire 开头,表示开始管道的流动。让后面的 handler 继续处理

示意图

ChannelPipeline 调度 handler 梳理:

  1. Context 包装 handler,多个 Context 在 pipeline 中形成了双向链表,入站方向叫 inbound,由 head 节点开始,出站方法叫 outbound ,由 tail 节点开始。
  2. 而节点中间的传递通过 AbstractChannelHandlerContext 类内部的 fire 系列方法,找到当前节点的下一个节点不断的循环传播。是一个过滤器形式完成对handler 的调度

5、Netty 心跳(heartbeat)服务源码剖析

Netty 作为一个网络框架,提供了诸多功能,比如编码解码等,Netty 还提供了非常重要的一个服务——心跳机制heartbeat。通过心跳检查对方是否有效,这是 RPC 框架中是必不可少的功能。

说明:

  1. Netty 提供了 IdleStateHandlerReadTimeoutHandlerWriteTimeoutHandler 三个Handler 检测连接的有效性,重点分析 IdleStateHandler
  2. image-20210823042900872

hasOutputChanged流程图:

hasOutputChanged流程图

6、Netty 核心组件 EventLoop 源码剖析

eventloop继承图:

eventloop继承图

handler 中加入线程池和Context 中添加线程池的源码剖析

  1. 在 Netty 中做耗时的,不可预料的操作,比如数据库,网络请求,会严重影响 Netty 对 Socket 的处理速度。
  2. 而解决方法就是将耗时任务添加到异步线程池中。但就添加线程池这步操作来讲,可以有2种方式,而且这2种方式实现的区别也蛮大的。
    • 处理耗时业务的第一种方式——handler 中加入线程池
    • 处理耗时业务的第二种方式——Context 中添加线程池

将这些任务从handler中提交到channel对应的NIOEventLoop 的 TaskQueue的方法:

用户程序自定义的普通任务 -> 提交到该channel 对应的NioEventLoop 的 taskQueue中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 用户程序自定义的普通任务
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵2", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});

/*
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(20 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵3", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});
*/

注意:

  • 该方法是通过ctx获得channel对象,在通过channel对象去获取该channel所在的evevtLoop,最后在将任务提交到eventLoop的taskQueue中

  • eventLoop会起一个线程去异步解决taskQueue当中的任务,==注意是一个线程==。如果taskQueue当中有多个任务的话,那么该线程会按照taskQueue中任务的顺序依次执行任务,即执行taskQueue任务的时间是累加的

    • eg:taskQueue的第一个任务花费10s,taskQueue的第二个任务花费20s,那么该线程执行完taskQueue当中的任务总共要花费30s
  • 解决方法:

    1. 在当前Handler中创建一个业务线程池,把耗时任务放到创建的线程池中执行。此时就变成了一个线程有一个业务线程池,来完成耗时任务的异步操作。(局部异步)

      • 创建线程池的方法:

        • // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);
          
          // 调用一下方法将耗时任务放在线程池创建的线程中进行执行
          group.sumbit(Callable task);
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15

          2. **在Server端中创建一个业务线程池(Context中添加线程池)**(整个异步)

          - 创建线程池的方法:

          - ```java
          // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);

          // 在ChannelInitializer的initChannel方法中
          ChannelPipeline p = chpipeline();
          // 在这里将group设置进去:如果这样设置的话,该handler会优先加入到该线程池中,这样一来,workerGroup主要接收任务 然后在将任务提交给线程池来处理。
          // 默认没添加group的话,handler会进入workerLoopGroup的某一个workerLoop子线程
          p.addLast(group,new MyServerHandler());

流程图:

流程图


12、用Netty 自己 实现 dubbo RPC

1、RPC基本介绍

  1. RPC(Remote Procedure Call)——远程过程调用,是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员无需额外地为这个交互作用编程
  2. 两个或多个应用程序都分布在不同的服务器上,它们之间的调用都像是本地方法调用一样
    • image-20210823043804591
  3. 常见的 RPC 框架有:比较知名的如:
    • 阿里的Dubbo
    • google的gRPC
    • Go语言的rpcx
    • Apache的thrift
    • Spring 旗下的 Spring Cloud

2、RPC调用流程

1、RPC调用流程图

image-20210823043954338

术语说明:在RPC 中, Client 叫服务消费者,Server 叫服务提供者

2、RPC调用流程说明

  1. 服务消费方(client)以本地调用方式调用服务
  2. client stub 接收到调用后负责将方法、参数等封装成能够进行网络传输的消息体
  3. client stub 将消息进行编码并发送到服务端
  4. server stub 收到消息后进行解码
  5. server stub 根据解码结果调用本地的服务
  6. 本地服务执行并将结果返回给 server stub
  7. server stub 将返回导入结果进行编码并发送至消费方
  8. client stub 接收到消息并进行解码
  9. 服务消费方(client)得到结果

小结:RPC 的目标就是将 2-8 这些步骤都封装起来,用户无需关心这些细节,可以像调用本地方法一样即可完成远程服务调用。

3、自己实现 dubbo RPC(基于Netty)

1、需求说明

  1. dubbo 底层使用了 Netty 作为网络通讯框架,要求用 Netty 实现一个简单的 RPC 框架
  2. 模仿 dubbo,消费者和提供者约定接口和协议,消费者远程调用提供者的服务,提供者返回一个字符串,消费者打印提供者返回的数据。底层网络通信使用 Netty 4.1.20

2、设计说明

  1. 创建一个接口,定义抽象方法。用于消费者和提供者之间的约定。
  2. 创建一个提供者,该类需要监听消费者的请求,并按照约定返回数据。
  3. 创建一个消费者,该类需要透明的调用自己不存在的方法,内部需要使用 Netty 请求提供者返回数据

image-20210823044457880


参考资料

linux I/O–IO原理和几种零拷贝机制

由传统IO演化至零拷贝的过程

[TOC]

JUC高并发编程

1、Java并发知识体系详解

1、知识体系

img

2、java高并发

Java并发1

Java并发2

Java并发3

Java并发4

Java并发思维导图(含面试问题整理)


2、Java 并发 - 理论基础

从理论的角度引入并发安全问题以及JMM应对并发问题的原理。

1、BAT大厂的面试问题

  • 多线程的出现是要解决什么问题的?
  • 线程不安全是指什么? 举例说明
  • 并发出现线程不安全的本质什么?
    • 并发的三要素:可见性,原子性和有序性。
  • Java是怎么解决并发问题的?
    • 3个关键字,JMM和8个Happens-Before
  • 线程安全是不是非真即假?
    • 不是
  • 线程安全有哪些实现思路?
  • 如何理解并发和并行的区别?

2、并发与并行

1、串行模式

串行表示所有任务都按先后顺序进行

串行意味着必须先装完一车柴才能运送这车柴,只有运送到了,才能卸下这车柴,并且只有完成了这整个三个步骤,才能进行下一个步骤。

串行是一次只能取得一个任务,并执行这个任务

2、并行模式

并行意味着可以同时取得多个任务,并同时去执行所取得的这些任务

并行模式相当于将长长的一条队列,划分成了多条短队列,所以并行缩短了任务队列的长度。

并行的效率从代码层次上强依赖于多进程/多线程代码从硬件角度上则依赖于多核 CPU

多核 cpu下,每个核(core)都可以调度运行线程,这时候线程可以是并行的。

image-20210802205153125

3、并发

并发(concurrent)指的是多个程序可以同时运行的现象,更细化的是多进程可以同时运行或者多指令可以同时运行。但这不是重点,在描述并发的时候也不会去扣这种字眼是否精确,==并发的重点在于它是一种现象==, ==并发描述的是多进程同时运行的现象==。但实际上,对于单核心 CPU 来说,同一时刻只能运行一个线程,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。所以,这里的”同时运行”表示的不是真的同一时刻有多个线程运行的现象,这是并行的概念,而是提供一种功能让用户看来多个程序同时运行起来了,但实际上这些程序中的进程不是一直霸占 CPU 的,而是执行一会停一会。 总结为一句话就是: ==微观串行,宏观并行== ,一般会将这种 线程轮流使用 CPU 的做法称为并发( concurrent

image-20210802204936365

要解决大并发问题,通常是将大任务分解成多个小任务,由于操作系统对进程的调度是随机的,所以切分成多个小任务后,可能会从任一小任务处执行。这可能会出现一些现象:

  • 可能出现一个小任务执行了多次,还没开始下个任务的情况。这时一般会采用队列或类似的数据结构来存放各个小任务的成果;
  • 可能出现还没准备好第一步就执行第二步的可能。这时,一般采用多路复用或异步的方式,比如只有准备好产生了事件通知才执行某个任务;
  • 可以多进程/多线程的方式并行执行这些小任务。也可以单进程/单线程执行这些小任务,这时很可能要配合多路复用才能达到较高的效率。

4、并发与并行的区别

  • 并发是指一个处理器同时处理多个任务。并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。

  • 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。就好像两个人各拿一把铁锨在挖坑,一小时后,每人一个大坑。所以无论从微观还是从宏观来看,二者都是一起执行的。

    并行和并发哪个好?并行和并发的概念和区别

  • 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。这就好像两个人用同一把铁锨,轮流挖坑,一小时后,两个人各挖一个小一点的坑,要想挖两个大一点得坑,一定会用两个小时。

    并行和并发哪个好?并行和并发的概念和区别

  • 并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)。(下图来自Erlang 之父 Joe Armstrong)

    img

  • 当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态。这种方式我们称之为并发(Concurrent)。

  • 当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

    并行和并发哪个好?并行和并发的概念和区别

  • 引用 Rob Pike(golang 语言的创造者) 的一段描述:

    • 并发(concurrent)是同一时间应对(dealing with)多件事情的能力
    • 并行(parallel)是同一时间动手做(doing)多件事情的能力
    • 例子:
      • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
      • 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
      • 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
  • 并发:同一时刻多个线程在访问同一个资源,多个线程对一个点

    • 例子:春运抢票 电商秒杀…
  • 并行:多项工作一起执行,之后再汇总

    • 例子:泡方便面,电水壶烧水,一边撕调料倒入桶中

5、管程

管程(monitor)是保证了同一时刻只有一个进程在管程内活动,即**管程内定义的操作在同一时刻只被一个进程调用(由编译器实现)**。但是这样并不能保证进程以设计的顺序执行。

JVM 中同步是基于进入和退出管程(monitor)对象实现的,每个对象都会有一个管程(monitor)对象,管程(monitor)会随着 java 对象一同创建和销毁。

执行线程首先要持有管程对象,然后才能执行方法,当方法完成之后会释放管程,方法在执行时候会持有管程,其他线程无法再获取同一个管程。

管程,在java中叫锁,在操作系统中叫监视器,是一种同步机制。

image-20210721233218350

6、用户线程和守护线程

  • 用户线程:平时用到的普通线程、自定义线程

  • 守护线程:运行在后台,是一种特殊的线程。比如垃圾回收线程。

  • 当主线程结束后,用户线程还在运行,JVM 存活;

  • 如果没有用户线程,都是守护线程,JVM 结束 。

    image-20210721233249742

  • 可以通过调用Thread.currentThread().isDaemon()查看当前线程是不是守护线程

  • 可以通过调用当前线程.setDaemon(true)将当前线程设置为守护线程

    • 这个方法应该在当前线程.start()执行之前设置

注意:

  • 垃圾回收器线程就是一种守护线程
  • Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求

3、为什么需要多线程

众所周知,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异;
    • 导致 可见性问题
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
    • 导致 原子性问题
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
    • 导致 有序性问题

4、多线程的应用

1、应用之异步调用

1、异步与同步

以调用方角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步
2、设计

多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了 5 秒钟,如果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停…

3、结论
  • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
  • tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程
  • ui 程序中,开线程进行其他操作,避免阻塞 ui 线程

2、应用之提高效率

充分利用多核 cpu 的优势,提高运行效率。想象下面的场景,执行 3 个计算,最后将计算结果汇总:

1
2
3
4
计算 1 花费 10 ms
计算 2 花费 11 ms
计算 3 花费 9 ms
汇总需要 1 ms
  • 如果是串行执行,那么总共花费的时间是 10 + 11 + 9 + 1 = 31ms
  • 但如果是四核 cpu,各个核心分别使用线程 1 执行计算 1,线程 2 执行计算 2,线程 3 执行计算 3,那么 3 个线程是并行的,花费时间只取决于最长的那个线程运行的时间,即 11ms 最后加上汇总时间只会花费 12ms

注意:需要在多核 cpu 才能提高效率,单核仍然时是轮流执行

结论
  1. 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活
  2. 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
    • 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(参考后文的【阿姆达尔定律】)
    • 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
  3. IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化

5、线程不安全示例

如果多个线程同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。

以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。

1
2
3
4
5
6
7
8
9
public class ThreadUnsafeExample {
private int cnt = 0;
public void add() {
cnt++;
}
public int get() {
return cnt;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) throws InterruptedException {
final int threadSize = 1000;
ThreadUnsafeExample example = new ThreadUnsafeExample();
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize; i++) {
executorService.execute(() -> {
example.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(example.get());
}

结果:

1
997 // 结果总是小于1000

6、并发出现问题的根源:并发三要素

上述代码输出的值为什么总是小于1000?并发出现问题的根源是什么?

  • 并发的三要素
    • 可见性
    • 原子性
    • 有序性

1、可见性:CPU缓存引起

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

例子:

代码:

1
2
3
4
5
6
//线程1执行的代码
int i = 0; // 在主存中的值
i = 10; // 在CPU1中高速缓存中

//线程2执行的代码
j = i; // 读取的依旧是主存当中i的值,对于CPU修改的i的值不可见

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值

2、原子性:分时复用引起

原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

经典的银行取钱问题:比如从账户A和账户B同时对一个银行账号取钱1000元,那么必然包括2个操作:

  1. 从银行账号读取余额,取钱1000元
  2. 取完钱之后,银行将账号的余额进行更新(-1000)

试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A取钱1000元之后,操作突然中止。然后又从B取出了1000元,取出1000元之后,再执行银行余额更新减去1000元的操作。这样就会导致账号减去了1000元,但是账户A与账户B一共取到了2000元。

所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

3、有序性:重排序引起

有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

1
2
3
4
int i = 0;              
boolean flag = false;
i = 1; //语句1
flag = true; //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?

  • 不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
1、CPU的指令重排序
1、名词
  • Clock Cycle Time:主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的Cycle Time 是 1s
    • 例如,运行一条加法指令一般需要一个时钟周期时间
  • CPI:有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数
  • IPC:IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数
  • CPU 执行时间:程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示
    • 程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time
2、指令重排序优化

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?

可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段

image-20210806220551047

术语参考:

  • instruction fetch (IF)
  • instruction decode (ID)
  • execute (EX)
  • memory access (MEM)
  • register write back (WB)

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位。

指令重排的前提是,重排指令不能影响结果,例如:

1
2
3
4
5
6
7
 // 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2
3、支持流水线的处理器

现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。

image-20210806220613006

4、SuperScalar 处理器

大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单元等,这样可以把多条指令也可以做到并行获取、译码等,CPU 可以在一个时钟周期内,执行多于一条指令,IPC > 1

image-20210806220634123

2、重排序(Java 内存模型JMM)
1、重排序的分类

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

img

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

2、处理器重排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例:

1
2
3
4
5
6
7
8
9
// Processor A
a = 1; //A1
x = b; //A2

// Processor B
b = 2; //B1
y = a; //B2

// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0

假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:

img

这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2 -> A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。

下面是常见处理器允许的重排序类型的列表:

Load-Load Load-Store Store-Store Store-Load 数据依赖
sparc-TSO N N N Y
x86 N N N Y
ia64 Y Y Y Y
PowerPC Y Y Y Y

上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。

从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。

  • ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。
  • ※注 2:上表中的 x86 包括 x64 及 AMD64。
  • ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
  • ※注 4:数据依赖性后文会专门说明。

为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。

StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)

3、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

注意:

  • 这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,
  • 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑
4、as-if-serial 语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

1
2
3
double pi  = 3.14;    //A
double r = 1.0; //B
double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:

img

如上图所示:

  1. A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。
  2. 因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。
  3. 但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。

下图是该程序的两种执行顺序:

img

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

5、程序顺序规则

根据 happens- before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens- before 关系:

  • A happens- before B;
  • B happens- before C;
  • A happens- before C;

这里的第 3 个 happens- before 关系,是根据 happens- before 的传递性推导出来的。

这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。如果 A happens- before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。

6、重排序对多线程的影响

重排序是否会改变多线程程序的执行结果。请看下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ReorderExample {
int a = 0;
boolean flag = false;

public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}

flag 变量是个标记,用来标识变量 a 是否已被写入。

这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?

答案是:不一定能看到。

由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

当操作 1 和操作 2 重排序时,可能会产生什么效果? 请看下面的程序执行时序图:(注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。)

img

如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!

下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:

img

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。

从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!

结论:

  • 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);
  • 在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果
3、顺序一致性(Java 内存模型JMM)
1、数据竞争与顺序一致性保证

当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:

  • 在一个线程中写一个变量
  • 在另一个线程读同一个变量
  • 而且写和读没有通过同步来排序

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序

JMM 对正确同步的多线程程序的内存一致性做了如下保证:

  • 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)
  • 程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同
  • 这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。
2、顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序

在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。 顺序一致性内存模型为程序员提供的视图如下:

img

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读 / 写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 / 写操作串行化。

为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。

  • 假设有两个线程 A 和 B 并发执行。
    • 其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。
    • B 线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
  • 假设这两个线程使用监视器来正确同步:
    • A 线程的三个操作执行后释放监视器,
    • 随后 B 线程获取同一个监视器。

那么程序在顺序一致性模型中的执行效果将如下图所示:

img

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

img

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如:在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

3、同步程序的顺序一致性效果

下面我们对前面的示例程序 ReorderExample 用监视器来同步,看看正确同步的程序如何具有顺序一致性。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() {
a = 1;
flag = true;
}

public synchronized void reader() {
if (flag) {
int i = a;
// ……
}
}
}

上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。

下面是该程序在两个内存模型中的执行时序对比图:

img

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门

4、未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来

为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。

JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。

和顺序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
  • JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。

第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。下面让我们通过一个示意图来说明总线的工作机制:

img

如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。

在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。

当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:

img

如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A”写了一半”的无效值。

4、总结
1、处理器内存模型

顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

根据对不同类型读 / 写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:

  • 放松了程序中写 - 读操作的顺序,由此产生了 total store ordering 内存模型(简称为 TSO)。
  • 在前面 1 的基础上,继续放松程序中写 - 写操作的顺序,由此产生了 partial store order 内存模型(简称为 PSO)。
  • 在前面 1 和 2 的基础上,继续放松程序中读 - 写和读 - 读操作的顺序,由此产生了 relaxed memory order 内存模型(简称为 RMO)和 PowerPC 内存模型。

注意:

  • 这里处理器对读 / 写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。

下面的表格展示了常见处理器内存模型的细节特征:

内存模型名称 对应的处理器 Store-Load 重排序 Store-Store 重排序 Load-Load 和 Load-Store 重排序 可以更早读取到其它处理器的写 可以更早读取到当前处理器的写
TSO sparc-TSO X64 Y Y
PSO sparc-PSO Y Y Y
RMO ia64 Y Y Y Y
PowerPC PowerPC Y Y Y Y Y

在这个表格中,我们可以看到所有处理器内存模型都允许写 - 读重排序,原因在前面说明过:它们都使用了写缓存区,写缓存区可能导致写 - 读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写

上面表格中的各种处理器内存模型,从上到下,模型由强变弱越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能

由于常见的处理器内存模型比 JMM 要弱,java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:

img

如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java 程序员呈现了一个一致的内存模型。

2、JMM、处理器内存模型与顺序一致性内存模型之间的关系
  • JMM 是一个语言级的内存模型
  • 处理器内存模型是硬件级的内存模型
  • 顺序一致性内存模型是一个理论参考模型

下面是语言内存模型、处理器内存模型和顺序一致性内存模型的强弱对比示意图:

img

从上图我们可以看出:

  • 常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱
  • 处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。
  • 同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。
3、JMM 的设计

从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:

  • 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码
  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型

由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:

  1. 一方面要**为程序员提供足够强的内存可见性保证**;
  2. 另一方面,**对编译器和处理器的限制要尽可能的放松**。

下面让我们看看 JSR-133 是如何实现这一目标的。为了具体说明,请看前面提到过的计算圆面积的示例代码:

1
2
3
double pi  = 3.14;    //A
double r = 1.0; //B
double area = pi * r * r; //C

上面计算圆的面积的示例代码存在三个 happens- before 关系:

  1. A happens- before B;
  2. B happens- before C;
  3. A happens- before C;

由于 A happens- before B,happens- before 的定义会要求:

  1. A 操作执行的结果要对 B 可见,且 A 操作的执行顺序排在 B 操作之前。
  2. 但是从程序语义的角度来说,对 A 和 B 做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。
  3. 也就是说,上面这 3 个 happens- before 关系中,虽然 2 和 3 是必需要的,但 1 是不必要的。因此,JMM 把 happens- before 要求禁止的重排序分为了下面两类:
    • 会改变程序执行结果的重排序。
    • 不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序,采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。

下面是 JMM 的设计示意图:

img

从上图可以看出两点:

  • JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens - before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
  • JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除(锁消除)。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
4、JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证
  • 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:

img

只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

5、JSR-133 对旧内存模型的修补

JSR-133 对 JDK5 之前的旧内存模型的修补主要有两个:

  • 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。
  • 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。现在,final 具有了初始化安全性。

7、JAVA是怎么解决并发问题的:JMM(Java内存模型)

JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。

JMM 体现在以下几个方面:

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响
  • 理解的第一个维度:核心知识点

    • JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
      • volatilesynchronizedfinal 三个关键字
      • Happens-Before 规则
  • 理解的第二个维度:可见性,有序性,原子性

    • 原子性

      • 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

      • 请分析以下哪些操作是原子性操作:

        1. x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
          
          1
          2
          3
          4

          2. ```java
          y = x;
          //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。
        2. x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
          
          1
          2
          3

          4. ```java
          x = x + 1; //语句4: 同语句3
      • 上面4个语句只有语句1的操作具备原子性。

      • 也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

      • 从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronizedLock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

    • 可见性

      • Java提供了volatile关键字来保证可见性。
      • 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
      • 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
      • 另外,通过synchronizedLock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
    • 有序性

      • 在Java里面,可以通过volatile关键字来保证一定的”有序性”(具体原理在下面讲述)。另外可以通过synchronizedLock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的

1、关键字:volatile、synchronized 和 final

1、volatile
2、synchronized
3、final

2、Happens-Before 规则

上面提到了可以用 volatilesynchronized保证有序性。除此之外,JVM 还规定了先行发生(Happens-Before)原则让一个操作无需控制就能先于另一个操作完成

从 JDK5 开始,java 使用新的 JSR-133 内存模型(本文除非特别说明,针对的都是 JSR- 133 内存模型)。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 happens-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见:(变量都是指成员变量或静态成员变量)

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

    • static int x;
      static Object m = new Object();
      new Thread(()->{
          synchronized(m) {
              x = 10;
          }
      },"t1").start();
      
      new Thread(()->{
          synchronized(m) {
              System.out.println(x);
          }
      },"t2").start();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

      - ```java
      volatile static int x;
      new Thread(()->{
      x = 10;
      },"t1").start();
      new Thread(()->{
      System.out.println(x);
      },"t2").start();
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见

    • static int x;
      x = 10;
      new Thread(()->{
          System.out.println(x); 
      },"t2").start();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)

      - ```java
      static int x;
      Thread t1 = new Thread(()->{
      x = 10;
      },"t1");
      t1.start();
      t1.join();
      System.out.println(x);
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)

    • static int x;
      public static void main(String[] args) {
          Thread t2 = new Thread(()->{
              while(true) {
                  if(Thread.currentThread().isInterrupted()) {
                      System.out.println(x);
                      break;
                  }
              }
          },"t2");
          t2.start();
      
          new Thread(()->{
              sleep(1);
              x = 10;
              t2.interrupt();
          },"t1").start();
      
          while(!t2.isInterrupted()) {
              Thread.yield();
          }
          System.out.println(x);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17

      - 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

      - 具有传递性,如果 `x hb-> y` 并且 `y hb-> z` 那么有 `x hb-> z` ,配合 `volatile` 的防指令重排,有下面的例子

      - ```java
      volatile static int x;
      static int y;
      new Thread(()->{
      y = 10;
      x = 20;
      },"t1").start();

      new Thread(()->{
      // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
      System.out.println(x);
      },"t2").start();

注意:

  • 两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!
  • happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

happens-before 与 JMM 的关系如下图所示:

img

如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

1、单一线程原则(Single Thread rule)

在一个线程内,在程序前面的操作先行发生于后面的操作。

image

2、管程锁定规则(Monitor Lock Rule)

一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

image

3、volatile 变量规则(Volatile Variable Rule)

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

image

4、线程启动规则(Thread Start Rule)

Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

image

5、线程加入规则(Thread Join Rule)

Thread 对象的结束先行发生于 join() 方法返回。

image

6、线程中断规则(Thread Interruption Rule)

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

7、对象终结规则(Finalizer Rule)

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

8、传递性(Transitivity)

如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

8、线程安全:不是一个非真即假的命题

一个类在可以被多个线程安全调用时就是线程安全的。

线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类:

  1. 不可变
  2. 绝对线程安全
  3. 相对线程安全
  4. 线程兼容
  5. 线程对立。

1、不可变(Immutable)

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。

多线程环境下,应当尽量使对象成为不可变,来满足线程安全

不可变的类型:

  • final 关键字修饰的基本数据类型

  • String

  • 枚举类型

  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。

    • 但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
  • 日期格式转换类 DateTimeFormatter

    • 平常用的的日期格式转换类 SimpleDateFormat 在多线程下是不安全的,有很大几率出现 java.lang.NumberFormatException 或者出现不正确的日期解析结果

    • 在 Java 8 后通过了 DateTimeFormatter 解决这个问题,在文档中你可以发现对DateTimeFormatter的描述:

      1
      2
      @implSpec
      This class is immutable and thread-safe.

对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。

Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常

1
2
3
4
5
6
7
public class ImmutableExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
unmodifiableMap.put("a", 1);
}
}
1
2
3
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
1
2
3
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
at ImmutableExample.main(ImmutableExample.java:9)
1、不可变的设计要素

String为例,说明一下不可变设计的要素:

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];

/** Cache the hash code for the string */
private int hash; // Default to 0

/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;

// ...
}
  • String整一个类被final修饰了,保证了String没有任何子类,所以也不用担心子类去修改重写它的方法而导致破坏不可变性
  • hash虽然没有加上什么final修饰,但是hash是私有的并且String类没有提供hash的set方法,外部没有办法修改hash的值,所以也算保证了hash的不可变性
  • char[]数组使用了final修饰,在构造方法当中赋值,保证了value值的不可变性;
  • 但是这样只是保证了char[]数组这个引用变量的不可变性,怎么保证char[]数组里面的值具有不可变性呢?
    • 主要是依赖了String的构造方法。

String的构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 无参
public String() {
this.value = "".value;
}

// 传递一个原始字符串,根据该字符串生成新字符串,它会与原始字符串共用一个value数组
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}

// 传递一个char[]数组,它会对char[]数组的内容进行拷贝,就复制一个新数组,新数组在作为String的value数组
// 这种思想:保护性拷贝
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
2、保护性拷贝(defensive copy)

使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那不就破坏了String的不可变性了吗?那么下面就看一看这些方法是如何实现的,就以 substring 为例:

1
2
3
4
5
6
7
8
9
10
11
12
public String substring(int beginIndex) {
// 一些常规判断
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 主要是这里:它会新new一个String并把value作为参数传递进去,保证不可变性
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

发现其内部是调用 String 的构造方法创建了一个新字符串,再进入这个构造看看,是否对 final char[] value 做出了修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
// 它会对数组的内容进行拷贝,就复制一个新数组,新数组在作为String的value数组(保护性拷贝)
this.value = Arrays.copyOfRange(value, offset, offset+count);
}

结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】

2、绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施。

3、相对线程安全

相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。

对于下面的代码,如果删除元素的线程删除了 Vector 的一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class VectorUnsafeExample {
private static Vector<Integer> vector = new Vector<>();

public static void main(String[] args) {
while (true) {
for (int i = 0; i < 100; i++) {
vector.add(i);
}
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
});
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
});
executorService.shutdown();
}
}
}
1
2
3
4
5
Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3
at java.util.Vector.remove(Vector.java:831)
at VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:14)
at VectorUnsafeExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
}
});

4、线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。

5、线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免

9、线程安全的实现方法

1、互斥同步

synchronizedReentrantLock

2、非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

1、CAS(JUC中CAS, Unsafe和原子类相关)

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:==先进行操作==**,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)**。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

乐观锁需要==操作和冲突检测这两个步骤具备原子性==,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:**比较并交换(Compare-and-Swap,CAS)**。

CAS 指令需要有 3 个操作数,分别是:

  • 内存地址 V
  • 旧的预期值 A
  • 新值 B。

当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。(否则一直循环重试,直到成功为止)

2、AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet()getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作

以下代码使用了 AtomicInteger 执行了自增的操作:

1
2
3
4
5
private AtomicInteger cnt = new AtomicInteger();

public void add() {
cnt.incrementAndGet();
}

以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt()

1
2
3
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代码是 getAndAddInt() 源码,其中:

  • var1 指示对象内存地址
  • var2 指示该字段相对对象内存地址的偏移
  • var4 指示操作需要加的数值,这里为 1

具体过程:

  1. 通过 getIntVolatile(var1, var2) 得到旧的预期值;
  2. 通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
  3. 可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。
1
2
3
4
5
6
7
8
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}
3、ABA

ABA问题:如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效

3、无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

1、栈封闭(JUC中线程池相关)

多个线程访问同一个方法的==局部变量==时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class StackClosedExample {
public void add100() {
int cnt = 0;
for (int i = 0; i < 100; i++) {
cnt++;
}
System.out.println(cnt);
}
}
1
2
3
4
5
6
7
public static void main(String[] args) {
StackClosedExample example = new StackClosedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> example.add100());
executorService.execute(() -> example.add100());
executorService.shutdown();
}
1
2
100
100
2、线程本地存储(Thread Local Storage)(JUC中ThreadLocal详解)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“==一个请求对应一个服务器线程”(Thread-per-Request)==的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 ==java.lang.ThreadLocal== 类来实现线程本地存储功能。

对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ThreadLocalExample {
public static void main(String[] args) {
ThreadLocal threadLocal = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal.set(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadLocal.get());
threadLocal.remove();
});
Thread thread2 = new Thread(() -> {
threadLocal.set(2);
threadLocal.remove();
});
thread1.start();
thread2.start();
}
}

输出结果:1

为了理解 ThreadLocal,先看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadLocalExample1 {
public static void main(String[] args) {
ThreadLocal threadLocal1 = new ThreadLocal();
ThreadLocal threadLocal2 = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal1.set(1);
threadLocal2.set(1);
});
Thread thread2 = new Thread(() -> {
threadLocal1.set(2);
threadLocal2.set(2);
});
thread1.start();
thread2.start();
}
}

它所对应的底层结构图为:

image

每个 Thread 都有一个 ==ThreadLocal.ThreadLocalMap 对象==,Thread 类中就定义了 ThreadLocal.ThreadLocalMap 成员。

1
2
3
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

当调用一个 ThreadLocal 的 set(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLoca -> value 键值对插入到该 Map 中。

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

get() 方法类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

hreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争

注意:

  • **在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ==ThreadLocal 有内存泄漏的情况==**;
  • 应该尽可能在每次使用 ThreadLocal 后==手动调用 remove()==,以==避免出现 ThreadLocal 经典的内存泄漏==甚至是造成自身业务混乱的风险。
3、可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征:

  • 例如不依赖存储在堆上的数据和公用的系统资源
  • 用到的状态量都由参数中传入
  • 不调用非可重入的方法等。
4、无状态

在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的

因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】


3、Java 并发 - 线程基础

1、BAT大厂的面试问题

  • 线程有哪几种状态?分别说明从一种状态到另一种状态转变有哪些方式?
  • 通常线程有哪几种使用方式?
  • 基础线程机制有哪些?
  • 线程的中断方式有哪些?
  • 线程的互斥同步方式有哪些?如何比较和选择?
  • 线程之间有哪些协作方式?

2、进程与线程

1、进程

进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。

在当代面向线程设计的计算机结构中,进程是线程的容器

进程:

  • 是程序的实体;
  • 是计算机中的程序关于某数据集合上的一次运行活动;
  • 是系统进行资源分配和调度的基本单位;
  • 是操作系统结构的基础。
  • 程序是指令、数据及其组织形式的描述,进程是程序的实体。

进程:

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器
    等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)

2、线程

线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

线程:

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
  • Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器

3、进程与线程的区别

  • 进程:指在系统中正在运行的一个应用程序;程序一旦运行就是进程;
    • 进程——资源分配的最小单位。
  • 线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流。
    • 线程——程序执行的最小单位。
  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享
  • 进程间通信较为复杂
    • 同一台计算机的进程通信称为 IPC(Inter-process communication)
      • 信号量:信号量是一个计数器,用于多进程对共享数据的访问,解决同步相关的问题并避免竞争条件
      • 共享存储:多个进程可以访问同一块内存空间,需要使用信号量用来同步对共享存储的访问
      • 管道通信:管道是用于连接一个读进程和一个写进程以实现它们之间通信的一个共享文件,pipe文件
        • 匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信,只支持半双工通信
        • 命名管道(Names Pipes):以磁盘文件的方式存在,可以实现本机任意两个进程通信,遵循FIFO
      • 消息队列:内核中存储消息的链表,由消息队列标识符标识,能在不同进程之间提供全双工通信,对比管道:
        • 匿名管道存在于内存中的文件;命名管道存在于实际的磁盘介质或者文件系统;消息队列存放在内核中,只有在内核重启(操作系统重启)或者显示地删除一个消息队列时,该消息队列才被真正删除
        • 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
      • 套接字:与其它通信机制不同的是,它可用于不同机器间的进程通信
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
    • Java中的通信机制:volatile、等待/通知机制、join方式、InheritableThreadLocal、MappedByteBuffer
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

3、线程状态转换

1、线程的五状态模型(操作系统)

image-20210803042737724

  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
  • 【运行状态】指获取了 CPU 时间片运行中的状态
    • 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
  • 【阻塞状态】
    • 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
    • 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
    • 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态

2、线程的七状态模型(操作系统)

img

3、线程的六状态模型(java)

这是从 Java API 层面来描述的

根据 Thread.State 枚举,分为六种状态:

image-20210803043141966

  • NEW 线程刚被创建,但是还没有调用 start() 方法
  • RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的【==可运行状态==】、【==运行状态==】和【==阻塞状态==】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)
  • BLOCKEDWAITINGTIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述
  • TERMINATED 当线程代码运行结束

image

假设有线程 Thread t

  1. NEW –> RUNNABLE
    • 当调用 t.start() 方法时,由 NEW --> RUNNABLE
  2. RUNNABLE <–> WAITING
    • t 线程用 synchronized(obj) 获取了对象锁后
      • 调用 obj.wait() 方法时,t 线程从 RUNNABLE --> WAITING
      • 调用 obj.notify()obj.notifyAll()t.interrupt()
        • 线程被notify之后直接从waitset进入entrylist,对应的状态就是WAITING --> BLOCKED
        • 等到锁释放之后,t线程进入锁的竞争
          • 竞争锁成功,t 线程从 WAITING --> RUNNABLE
          • 竞争锁失败,t 线程从 WAITING --> BLOCKED
  3. RUNNABLE <–> WAITING
    • 当前线程调用 t.join() 方法时,当前线程从 RUNNABLE --> WAITING
      • 注意是当前线程在t 线程对象的监视器上等待
    • t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE
  4. RUNNABLE <–> WAITING
    • 当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE --> WAITING
    • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING --> RUNNABLE
  5. RUNNABLE <–> TIMED_WAITING
    • t 线程用 synchronized(obj) 获取了对象锁后
      • 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING
      • t 线程等待时间超过了 n 毫秒,或调用 obj.notify()obj.notifyAll()t.interrupt()
        • 线程被notify之后直接从waitset进入entrylist,对应的状态就是WAITING --> BLOCKED
        • 等到锁释放之后,t线程进入锁的竞争
          • 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
          • 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED
  6. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING
      • 注意是当前线程在t 线程对象的监视器上等待
    • 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从TIMED_WAITING --> RUNNABLE
  7. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE --> TIMED_WAITING
    • 当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING --> RUNNABLE
  8. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 LockSupport.parkNanos(long nanos)LockSupport.parkUntil(long millis) 时,当前线程从 RUNNABLE --> TIMED_WAITING
    • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE
  9. RUNNABLE <–> BLOCKED
    • t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE --> BLOCKED
    • 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争成功,从 BLOCKED --> RUNNABLE ,其它失败的线程仍然 BLOCKED
  10. RUNNABLE <–> TERMINATED
    • 当前线程所有代码运行完毕,进入 TERMINATED

线程一共有六种状态:

  • 新建(new)
  • 可运行(runnable)
  • 阻塞(blocking)
  • 无限期等待(waiting)
  • 限期等待(timed waiting)
  • 死亡(terminated)
1、新建(New)

创建后尚未启动

2、可运行(Runnable)

可能正在运行,也可能正在等待 CPU 时间片。

包含了操作系统线程状态中的 RunningReady

3、阻塞(Blocking)

等待获取一个排它锁,如果其线程释放了锁就会结束此状态

4、无限期等待(Waiting)

等待其它线程显式地唤醒,否则不会被分配 CPU 时间片

进入方法 退出方法
没有设置 Timeout 参数的 Object.wait() 方法 Object.notify() / Object.notifyAll()
没有设置 Timeout 参数的 Thread.join() 方法 被调用的线程执行完毕
LockSupport.park() 方法 -
5、限期等待(Timed Waiting)

无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。

  • 调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。
  • 调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。

睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态

阻塞和等待的区别:

  • 阻塞是被动的,它是在等待获取一个排它锁
  • 等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入
进入方法 退出方法
Thread.sleep() 方法 时间结束
设置了 Timeout 参数的 Object.wait() 方法 时间结束 / Object.notify() / Object.notifyAll()
设置了 Timeout 参数的 Thread.join() 方法 时间结束 / 被调用的线程执行完毕
LockSupport.parkNanos() 方法 -
LockSupport.parkUntil() 方法 -
6、死亡(Terminated)

可以是线程结束任务之后自己结束,或者产生了异常而结束。

4、线程的四种使用方式

有三种使用线程的方法:

  • 实现 Runnable 接口
  • 实现 Callable 接口
  • 继承 Thread 类
  • 使用线程池

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。

1、实现 Runnable 接口

  1. 编写需要的类并实现Runnable接口,实现里面的 run() 方法。
  2. 通过 Thread 调用 start() 方法来启动线程。

代码:

1
2
3
4
5
6
public class MyRunnable implements Runnable {
@Override
public void run() {
// ...
}
}
1
2
3
4
5
6
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}

实现 Runnable 接口的优缺点:

  • 缺点:代码复杂一点。
  • 优点:
    1. 线程任务类只是实现了Runnable接口,可以继续继承其他类,避免了单继承的局限性
    2. 同一个线程任务对象可以被包装成多个线程对象
    3. 适合多个多个线程去共享同一个资源
    4. 实现解耦操作,线程任务代码可以被多个线程共享,线程任务代码和线程独立
    5. 线程池可以放入实现Runnable或Callable线程任务对象

2、实现 Callable 接口

与Runnable 接口大致相同:

  1. 编写需要的类并实现Callable接口,实现里面的 call() 方法,该方法有返回值
  2. 通过 Thread 调用 start() 方法来启动线程。

区别是:与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装

1
2
3
4
5
6
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
return 123;
}
}
1
2
3
4
5
6
7
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}

实现 Callable 接口的优缺点:

  • 优点:同 Runnable,并且能得到线程执行的结果
  • 缺点:编码复杂

3、继承 Thread 类

同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。

当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。

建议线程先创建子线程,主线程的任务放在之后,否则主线程(main)永远是先执行完

1
2
3
4
5
6
public class MyThread extends Thread {
@Override
public void run() {
// ...
}
}
1
2
3
4
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}

继承 Thread 类的优缺点:

  • 优点:编码简单
  • 缺点:线程类已经继承了Thread类无法继承其他类了,功能不能通过继承拓展(单继承的局限性)

4、使用线程池

Java标准库提供了ExecutorService接口表示线程池,因为ExecutorService只是接口,Java标准库提供的几个常用实现类有:

  • FixedThreadPool:线程数固定的线程池;
  • CachedThreadPool:线程数根据任务动态调整的线程池;
  • SingleThreadExecutor:仅单线程执行的线程池。

创建这些线程池的方法都被封装到Executors这个类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.util.concurrent.*;

public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}

class Task implements Runnable {
private final String name;

public Task(String name) {
this.name = name;
}

@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}

线程池的具体细节放在下面线程池篇具体说明

5、Thread 与 Runnable 的底层关系

使用Runnable的方法创建线程的代码:

1
2
Thread t = new Thread(()->{ log.debug("running"); }, "t2");
t.start();

Thread底层代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// Thread的一个构造函数
public Thread(Runnable target, String name) {
init(null, target, name, 0);
}

// init调用了Thread的init初始化函数,其中将Runnable作为target对象传入
private void init(ThreadGroup g, Runnable target, String name,
long stackSize) {
init(g, target, name, stackSize, null, true);
}

// 该init函数调用了重载的其他init函数
private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
if (name == null) {
throw new NullPointerException("name cannot be null");
}

this.name = name;

Thread parent = currentThread();
SecurityManager security = System.getSecurityManager();
if (g == null) {
if (security != null) {
g = security.getThreadGroup();
}
if (g == null) {
g = parent.getThreadGroup();
}
}
g.checkAccess();

if (security != null) {
if (isCCLOverridden(getClass())) {
security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
}
}

g.addUnstarted();

this.group = g;
this.daemon = parent.isDaemon();
this.priority = parent.getPriority();
if (security == null || isCCLOverridden(parent.getClass()))
this.contextClassLoader = parent.getContextClassLoader();
else
this.contextClassLoader = parent.contextClassLoader;
this.inheritedAccessControlContext =
acc != null ? acc : AccessController.getContext();
// 主要是这一句代码:将Runnable对象的target赋值给Thread本身的target
this.target = target;
setPriority(priority);
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
/* Stash the specified stack size in case the VM cares */
this.stackSize = stackSize;

/* Set thread ID */
tid = nextThreadID();
}

// 在该init函数里面将Runnable对象的target赋值给Thread本身的target,然后在Thread内部调用了run方法
@Override
public void run() {
if (target != null) {
target.run();
}
}

总结:

  • Thread类本身实现了Runnable接口
  • 如果直接使用Thread的方式创建线程对象,则原理是重写了Thread的run方法
  • 如果使用的Runnable的方式创建线程对象,在原理是将Runnable对象封装成target,在Thread中调用target.run方法

6、实现接口 VS 继承 Thread

实现接口会更好一些,因为:

  • 使用接口更容易与线程池等高级 API 配合
  • 使用接口让任务类脱离了 Thread 继承体系,更灵活
  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大

7、调用start()方法,线程是否会马上创建?

  • 线程不一定马上创建的
  • 看start()方法的源码知道start()方法底层调用了start0()方法,这是一个被native修饰的方法,它的调用依赖于操作系统
  • 当操作系统认为当前可以创建线程的时候,线程才会被创建

8、查看进程线程的方法

  • windows
    • 任务管理器可以查看进程和线程数,也可以用来杀死进程
    • tasklist 查看进程
    • taskkill 杀死进程
  • linux
    • ps -fe 查看所有进程
    • ps -fT -p <PID> 查看某个进程(PID)的所有线程
    • kill 杀死进程
    • top 按大写 H 切换是否显示线程
    • top -H -p <PID> 查看某个进程(PID)的所有线程
  • Java
    • jps 命令 查看所有 Java 进程
    • jstack <PID> 查看某个 Java 进程(PID)的所有线程状态
    • jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)
      • jconsole 远程监控配置:
        • 需要以如下方式运行你的 java 类
          • java -Djava.rmi.server.hostname=ip地址 -Dcom.sun.management.jmxremote -
            Dcom.sun.management.jmxremote.port=连接端口 -Dcom.sun.management.jmxremote.ssl=是否安全连接 -Dcom.sun.management.jmxremote.authenticate=是否认证 java类
        • 修改 /etc/hosts 文件将 127.0.0.1 映射至主机名
        • 如果要认证访问,还需要做如下步骤:
          • 复制 jmxremote.password 文件
          • 修改 jmxremote.password 和 jmxremote.access 文件的权限为 600 即文件所有者可读写
          • 连接时填入 controlRole(用户名),R&D(密码)

9、线程运行的原理

1、栈与栈帧

JVM 中由堆、栈、方法区所组成。

Java Virtual Machine Stacks (Java 虚拟机栈):每个线程启动后,虚拟机就会为其分配一块栈内存

我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?

其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
2、线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码。原因:

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleepyieldwaitjoinparksynchronizedlock 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Java 创建的线程是内核级线程,线程的调度是在内核态运行的,而线程中的代码是在用户态运行,所以线程切换(状态改变)会导致用户与内核态转换,这是非常消耗性能
  • Java 中 main 方法启动的是一个进程也是一个主线程,main 方法里面的其他线程均为子线程

5、线程的常见方法

方法名 static(静态) 功能说明 注意
start() 启动一个新线程,在新的线程运行 run 方法中的代码 start 方法只是让线程进入就绪,里面代码不一定立刻运行(CPU 的时间片还没分给它)。每个线程对象的start方法只能调用一次,如果调用了多次会出现 IllegalThreadStateException
run() 新线程启动后会调用的方法 如果在构造 Thread 对象时传递了 Runnable 参数,则线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。但可以创建 Thread 的子类对象,来覆盖默认行为
join() 等待线程运行结束
join(long n) 等待线程运行结束,最多等待 n 毫秒
getId() 获取线程长整型的 id id 唯一
getName() 获取线程名
setName(String) 修改线程名
getPriority() 获取线程优先级
setPriority(int) 修改线程优先级 java中规定线程优先级是1~10 的整数,较大的优先级能提高该线程被 CPU 调度的机率
getState() 获取线程状态 Java 中线程状态是用 6 个 enum 表示,分别为:NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
isInterrupted() 判断是否被打断 不会清除==打断标记==
isAlive() 线程是否存活(还没有运行完毕)
interrupt() 打断线程 如果被打断线程正在 sleepwaitjoin 会导致被打断的线程抛出 InterruptedException,并清除==打断标记== ;如果打断的正在运行的线程,则会设置==打断标记==;park 的线程被打断,也会设置==打断标记==
interrupted() static 判断当前线程是否被打断 会清除==打断标记==
currentThread() static 获取当前正在执行的线程
sleep(long n) static 让当前执行的线程休眠n毫秒,休眠时让出 cpu 的时间片给其它线程
yield() static 提示线程调度器让出当前线程对CPU的使用 主要是为了测试和调试

6、不推荐的方法

还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁

方法名 static 功能说明
stop() 停止线程运行
suspend() 挂起(暂停)线程运行
resume() 恢复线程运行

7、基础线程机制

1、Executor

Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。

主要有三种 Executor:

  • CachedThreadPool: 一个任务创建一个线程;
  • FixedThreadPool:所有任务只能使用固定大小的线程;
  • SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。

具体使用:(代码)

1
2
3
4
5
6
7
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executorService.execute(new MyRunnable());
}
executorService.shutdown();
}

2、Daemon

守护线程是程序运行时在后台提供服务的线程,属于程序中不可或缺的一部分

当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程

main() 属于非守护线程。

使用 setDaemon() 方法将一个线程设置为守护线程。

1
2
3
4
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}

3、sleep()

Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为==毫秒==。

sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。

1
2
3
4
5
6
7
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
案例——防止CPU占用100%

在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权给其他程序

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestCpu {
public static void main(String[] args) {
new Thread(() -> {
while(true) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
  • 可以用 wait 或 条件变量达到类似的效果
  • 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
  • sleep 适用于无需锁同步的场景

4、yield()

对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行(让位操作)。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。

1
2
3
public void run() {
Thread.yield();
}

5、run/start、sleep/yield、线程优先级

1、run与start
1、run

run:称为线程体,包含了要执行的这个线程的内容,方法运行结束,此线程随即终止。直接调用 run 是在主线程中执行了 run,没有启动新的线程,需要顺序执行

2、start

start:使用 start 是启动新的线程,此线程处于就绪(可运行)状态,通过新的线程间接执行 run 中的代码

说明:线程控制资源类

3、面试问题:run() 方法中的异常不能抛出,只能 try/catch
  • 因为父类中没有抛出任何异常,子类不能比父类抛出更多的异常
  • 异常不能跨线程传播回 main() 中,因此必须在本地进行处理
4、run与start之间的区别
  • 直接调用 run 是在主线程中执行了 run,没有启动新的线程,相当于变成了普通类的执行,此时将只有主线程在执行该线程

  • 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码

  • 调用start()方法之前与之后线程的状态:

    • 代码:

      • public class Test5 {
            public static void main(String[] args) {
                Thread t1 = new Thread("t1") {
                    @Override
                    public void run() {
                        log.debug("running...");
                    }
                };
        
                System.out.println(t1.getState());
                t1.start();
                System.out.println(t1.getState());
            }
        }
        
        1
        2
        3
        4
        5
        6
        7

        - 结果:

        - ```sh
        NEW
        RUNNABLE
        12:51:05.298 [t1] c.Test5 - running...
2、sleep与yield之间的区别
1、sleep
  • 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
    • 使用sleep后,线程失去cpu的时间片。同时也不能在获取cpu的时间片。
  1. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
  2. 睡眠结束后的线程未必会立刻得到执行
  3. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
2、yield
  • 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
    • 使用yield后,如果线程进入Runnable就绪状态还是有可能签到cpu时间片的,这是与sleep()最大的不同
  1. 具体的实现依赖于操作系统的任务调度器
  2. 会放弃 CPU 资源,锁资源不会释放
3、线程优先级(priority)
  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用

8、线程中断

一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。

1、InterruptedException

通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞

对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InterruptExample {

private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
1
2
3
4
5
6
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
1
2
3
4
5
6
7
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

2、interrupted()

如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束

但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程

1
2
3
4
5
6
7
8
9
10
11
12
public class InterruptExample {

private static class MyThread2 extends Thread {
@Override
public void run() {
while (!interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
}
1
2
3
4
5
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
1
Thread end

3、Executor 的中断操作

  1. 调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,
  2. 但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。

以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
executorService.shutdownNow();
System.out.println("Main run");
}
1
2
3
4
5
6
7
8
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at ExecutorInterruptExample.lambda$main$0(ExecutorInterruptExample.java:9)
at ExecutorInterruptExample$$Lambda$1/1160460865.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)

如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future<?> 对象,通过调用该对象的 cancel(true) 方法就可以中断线程

1
2
3
4
Future<?> future = executorService.submit(() -> {
// ..
});
future.cancel(true);

9、线程互斥同步

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问:

  1. 第一个是 JVM 实现的 synchronized;
  2. 而另一个是 JDK 实现的 ReentrantLock。

1、synchronized

1、同步一个代码块
1
2
3
4
5
public void func() {
synchronized (this) {
// ...
}
}

它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步

对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。

1
2
3
4
5
6
7
8
9
10
public class SynchronizedExample {

public void func1() {
synchronized (this) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func1());
executorService.execute(() -> e1.func1());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
2、同步一个方法
1
2
3
public synchronized void func () {
// ...
}

它和同步代码块一样,作用于同一个对象。

3、同步一个类
1
2
3
4
5
public void func() {
synchronized (SynchronizedExample.class) {
// ...
}
}

作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步

1
2
3
4
5
6
7
8
9
10
public class SynchronizedExample {

public void func2() {
synchronized (SynchronizedExample.class) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
1
2
3
4
5
6
7
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
SynchronizedExample e2 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func2());
executorService.execute(() -> e2.func2());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
4、同步一个静态方法
1
2
3
public synchronized static void fun() {
// ...
}

作用于整个类。

2、ReentrantLock(JUC中的ReentrantLock)

ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class LockExample {

private Lock lock = new ReentrantLock();

public void func() {
lock.lock();
try {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
} finally {
lock.unlock(); // 确保释放锁,从而避免发生死锁。
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
LockExample lockExample = new LockExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> lockExample.func());
executorService.execute(() -> lockExample.func());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

3、比较

  • 锁的实现
    • synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
  • 性能
    • 新版本 Java 对 synchronized 进行了很多优化,例如==自旋锁==等,synchronized 与 ReentrantLock 大致相同。
  • 等待可中断
    • 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情
      • ReentrantLock 可中断
      • 而 synchronized 不行
  • 公平锁
    • 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
      • synchronized 中的锁是非公平的
      • ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
  • 锁绑定多个条件
    • 一个 ReentrantLock 可以同时绑定多个 Condition 对象。

4、使用选择

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized

这是因为:

  1. synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。
  2. 并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

10、线程之间的协作

当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调

1、join()

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。

原理:调用者轮询检查线程 alive 状态,t1.join()等价于:原理:调用者轮询检查线程 alive 状态,t1.join()等价于:

1
2
3
4
5
6
synchronized (t1) {
// 调用者线程进入 t1 的 waitSet 等待, 直到 t1 运行结束
while (t1.isAlive()) {
t1.wait(0);
}
}
  • join 方法是被 synchronized 修饰的,本质上是一个对象锁,其内部的 wait 方法调用也是释放锁的,但是释放的是当前线程的对象锁,而不是外面的锁
  • t1 会强占 CPU 资源,直至线程执行结束,当调用某个线程的 join 方法后,该线程抢占到 CPU 资源,就不再释放,直到线程执行完毕

线程同步:

  • join 实现线程同步,因为会阻塞等待另一个线程的结束,才能继续向下运行
    • 需要外部共享变量,不符合面向对象封装的思想
    • 必须等待线程结束,不能配合线程池使用
  • Future 实现(同步):get() 方法阻塞等待执行结果
    • main 线程接收结果
    • get 方法是让调用线程同步等待
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Test {
static int r = 0;
public static void main(String[] args) throws InterruptedException {
test1();
}
private static void test1() throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
r = 10;
});
t1.start();
t1.join();//不等待线程执行结束,输出的10
System.out.println(r);
}
}
1、为什么需要join()

如果想要某线程(A)优先于某线程(B)运行(场景:线程B需要线程A的运算结果),这个时候就得线程B就需要使用join()来挂起当前线程,直到目标线程(A)结束。

对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class JoinExample {

private class A extends Thread {
@Override
public void run() {
System.out.println("A");
}
}

private class B extends Thread {

private A a;

B(A a) {
this.a = a;
}

@Override
public void run() {
try {
a.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("B");
}
}

public void test() {
A a = new A();
B b = new B(a);
b.start();
a.start();
}
}
1
2
3
4
public static void main(String[] args) {
JoinExample example = new JoinExample();
example.test();
}
1
2
A
B
2、为什么不用sleep(),而使用join()

使用sleep也可以实现以上效果,但是不好:因为在设计情况下你不清楚A线程需要多次时间得到运算结果,所以B线程不知道要sleep多少时间。

3、join(long)

join(long)可以设置等待时间,单位是ms。

  • 如果到了设置的时间还没有结果,线程会结束等待,继续往下运行
  • 如果在设置的时间之前就应经有结果了,线程会立即往下运行,不会等到设定的时间
4、join的底层原理——保护性暂停模式的时间增强

先看一下join的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public final void join() throws InterruptedException {
join(0);
}

public final synchronized void join(long millis)
throws InterruptedException {
// 开始时间
long base = System.currentTimeMillis();
// 经历时间
long now = 0;

// 相关判断
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}

if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
// 延迟时间,相当于保护性暂停中的waitTime
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}

可以将join的底层实现与保护性暂停模式的时间增强进行对比,会发现join的底层用的是保护性暂停模式的时间增强

2、wait()、notify()、notifyAll()

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待

    • wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到notify 为止

    • wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是被 notify

    • 其实还有一个wait(long timeout, int nanos)方法,只是这个方法是一个无效方法:它的意思是可以把时间精确到纳秒,而实际上无论你在第二个参数填写什么值(大于0小于999999),他都只是将第一个参数的值加一

    • public final void wait(long timeout, int nanos) throws InterruptedException {
          if (timeout < 0) {
              throw new IllegalArgumentException("timeout value is negative");
          }
      
          if (nanos < 0 || nanos > 999999) {
              throw new IllegalArgumentException(
                  "nanosecond timeout value out of range");
          }
      
          if (nanos > 0) {
              timeout++;
          }
      
          wait(timeout);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27

      - `obj.notify()` 在 object 上正在 waitSet 等待的线程中挑一个唤醒

      - `obj.notifyAll()` 让 object 上正在 waitSet 等待的线程全部唤醒

      **它们都属于 Object 的一部分,而不属于 Thread**。

      **==只能用在同步方法或者同步控制块中使用==**,否则会在运行时抛出 `IllegalMonitorStateExeception`。也侧面说明了wait/notify只能用在重量级锁。

      **使用 wait() 挂起期间,线程会释放锁**。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

      ```java
      public class WaitNotifyExample {
      public synchronized void before() {
      System.out.println("before");
      notifyAll();
      }

      public synchronized void after() {
      try {
      wait();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println("after");
      }
      }
1
2
3
4
5
6
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
WaitNotifyExample example = new WaitNotifyExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
1
2
before
after
1、wait() 和 sleep() 的区别
  • sleep 是 Thread 的静态方法,wait 是 Object 的方法,任何对象实例都能调用
  • **sleep 不会释放锁,它也不需要占用锁。wait 会释放锁,但调用它的前提是当前线程占有锁(即代码要在 synchronized 中)**。
  • sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
2、wait() 和 sleep() 的共同点
  • 它们都可以被 interrupted 方法中断
  • 它们状态 TIMED_WAITING
  • 在哪里睡着,就在哪里醒来

3、await()、signal()、signalAll()

java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活

使用 Lock 来获取一个 Condition 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class AwaitSignalExample {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

public void before() {
lock.lock();
try {
System.out.println("before");
condition.signalAll();
} finally {
lock.unlock();
}
}

public void after() {
lock.lock();
try {
condition.await();
System.out.println("after");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
AwaitSignalExample example = new AwaitSignalExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
1
2
before
after

4、interrupt

1、打断 sleep,wait,join 的线程

sleep,wait,join、这几个方法都会让线程进入阻塞状态(join底层就是wait,其实join与wait本质上是一样的)

可以使用interrupt方法来打断线程:

  • 如果打断的是阻塞的线程,会清空打断状态,打断状态为false
  • 如果打断的是正常运行的线程,不会清空打断状态,打断状态为true
    • 对于Running的线程,也就是正常运行的线程被打断(interrupt)后,不会立刻中断它,而是将其的打断标记isInterrupted()设置为true,可以在正常运行的线程中通过这个打断标记来选择是否终止自身线程。
    • 也就是说:因为直接把线程终结了,人家线程事情都没干完。不如跟他说一声,说我要打断你,他处理完事情后自行了断不更好
2、多线程设计模式——两阶段终止

详细请看——并发的相关多线程设计模式

3、打断 park 线程

打断 park 线程,不会清空打断状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) {
test3();
}

private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}, "t1");
t1.start();

sleep(1);
t1.interrupt();
}

输出:

1
2
3
21:11:19.373 [t1] c.TestInterrupt - park... 
21:11:20.371 [t1] c.TestInterrupt - unpark...
21:11:20.371 [t1] c.TestInterrupt - 打断状态:true

如果打断标记已经是 true,则 park 会失效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
test3();
}

private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
LockSupport.park();
log.debug("unpark...");
}, "t1");
t1.start();

sleep(1);
t1.interrupt();
}

输出:

1
2
3
4
21:11:54.003 [t1] c.TestInterrupt - park... 
21:11:55.002 [t1] c.TestInterrupt - unpark...
21:11:55.002 [t1] c.TestInterrupt - 打断状态:true
21:11:55.005 [t1] c.TestInterrupt - unpark...

提示:可以使用 Thread.interrupted() 清除打断状态

1
log.debug("打断状态:{}", Thread.currentThread().interrupted());

4、关键字:synchronized详解

在C程序代码中我们可以利用操作系统提供的互斥锁来实现同步块的互斥访问及线程的阻塞及唤醒等工作。在Java中除了提供Lock API外还在语法层面上提供了synchronized关键字来实现互斥同步原语。

1、BAT大厂的面试问题

  • Synchronized可以作用在哪里?分别通过对象锁和类锁进行举例。
  • Synchronized本质上是通过什么保证线程安全的?
    • 分三个方面回答:
      • 加锁和释放锁的原理
      • 可重入原理
      • 保证可见性原理
  • Synchronized有什么样的缺陷?Java Lock是怎么弥补这些缺陷的?
  • Synchronized和Lock的对比,和选择?
  • Synchronized在使用时有何注意事项?
  • Synchronized修饰的方法在抛出异常时,会释放锁吗?
  • 多个线程等待同一个snchronized锁的时候,JVM如何选择下一个获取锁的线程?
  • Synchronized使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?
  • 我想更加灵活地控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?
  • 什么是锁的升级和降级?什么是JVM里的偏斜锁、轻量级锁、重量级锁?
  • 不同的JDK中对Synchronized有何优化?

2、Synchronized的使用

在应用Sychronized关键字时需要把握如下注意点:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响
  • 例外:*锁对象是.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁**。
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会==释放锁==

1、对象锁

包括==方法锁==(默认锁对象为this当前实例对象)和==同步代码块锁==(自己指定锁对象)

1、代码块形式

手动指定锁定对象:

  • 可以是this:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instence = new SynchronizedObjectLock();

    @Override
    public void run() {
    // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
    synchronized (this) {
    System.out.println("我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println(Thread.currentThread().getName() + "结束");
    }
    }

    public static void main(String[] args) {
    Thread t1 = new Thread(instence);
    Thread t2 = new Thread(instence);
    t1.start();
    t2.start();
    }
    }
    1
    2
    3
    4
    我是线程Thread-0
    Thread-0结束
    我是线程Thread-1
    Thread-1结束
  • 也可以是自定义的锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instence = new SynchronizedObjectLock();
    // 创建2把锁
    Object block1 = new Object();
    Object block2 = new Object();

    @Override
    public void run() {
    // 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
    synchronized (block1) {
    System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
    }

    synchronized (block2) {
    System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
    }
    }

    public static void main(String[] args) {
    Thread t1 = new Thread(instence);
    Thread t2 = new Thread(instence);
    t1.start();
    t2.start();
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    block1锁,我是线程Thread-0
    block1锁,Thread-0结束
    block2锁,我是线程Thread-0  // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
    block1锁,我是线程Thread-1
    block2锁,Thread-0结束
    block1锁,Thread-1结束
    block2锁,我是线程Thread-1
    block2锁,Thread-1结束
2、方法锁形式:synchronized修饰普通方法,锁对象默认为this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
Thread t1 = new Thread(instence);
Thread t2 = new Thread(instence);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

2、类锁

指synchronize修饰静态的方法或指定锁对象为Class对象。

1、synchronize修饰静态方法

synchronize修饰普通方法与修饰静态方法的区别:

  • synchronized用在普通方法上,默认的锁就是this,当前实例
  • synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把

修饰普通方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

// synchronized用在普通方法上,默认的锁就是this,当前实例
public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
// t1和t2对应的this是两个不同的实例,所以代码不会串行
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
我是线程Thread-1
Thread-1结束
Thread-0结束

修饰静态方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

// synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
public static synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
2、synchronized指定锁对象为Class对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
// 所有线程需要的锁都是同一把
synchronized(SynchronizedObjectLock.class){
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}

public static void main(String[] args) {
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

3、关于synchronized锁的总结

对于Synchronized实现同步的基础:java中每一个对象都可以作为锁。

具体可以分为以下三种情况:

  • 对于普通同步方法,锁是当前实例对象;(对象锁)
  • 对于静态同步方法,锁是当前类的Class 对象;(类锁)
  • 对于同步方法块,锁是Synchonized 括号里配置的对象

对于对象锁:

  • 如果一个实例对象非静态同步方法获取锁后,该实例对象的其他非静态同步方法必须等待获取锁的方法释放锁后才能获取锁;
  • 别的实例对象的非静态同步方法因为跟该实例对象的非静态同步方法用的是不同的锁, 所以无须等待该实例对象已获取锁的非静态同步方法释放锁就可以获取他们自己的锁;
  • 每一个对象都有属于自己的对象锁(可以有多把对象锁)

对于类锁:

  • 所有的静态同步方法用的也是同一把锁——类对象本身(类锁),这与对象锁是两个不同的对象,所以静态同步方法与非静态同步方法之间是不会有竞态条件的;
  • 一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁;
  • 但不管是同一个实例对象的静态同步方法之间,还是不同的实例对象的静态同步方法之间,只要它们同一个类的实例对象
  • 类锁只有一把

对于同步代码块:

  • 同步代码块的锁是Synchonized 括号里配置的对象;
  • 如果Synchonized 括号里是对象,那么他就是对象锁;如果Synchonized 括号里是类,那么他就是类锁;
  • 所以他可以有一把,也可以有多把(主要看如果Synchonized 括号里是类还是对象)

举个例子:把synchronized的锁看成一座大楼

  • 类锁就是锁住大楼的锁
  • 对象锁就是锁住大楼里面房间的锁,每一个房间都有属于它的一把锁

3、Synchronized的原理分析

1、加锁和释放锁的原理

现象、时机(内置锁this)、深入JVM看字节码(反编译看monitor指令)

  1. 深入JVM看字节码,创建如下的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    public class SynchronizedDemo2{
    static final Object lock = new Object(); static int counter = 0;
    public static void main(String[] args) {
    synchronized (lock) {
    counter++;
    }
    }
    }
  2. 使用javac命令进行编译生成.class文件

    1
    >javac SynchronizedDemo2.java
  3. 使用javap命令反编译查看.class文件的信息

    1
    >javap -verbose SynchronizedDemo2.class
  4. 得到如下的信息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    Code:
    stack=2,locals=3,args_size=1
    0: getstatic #2 // <- lock引用( synchronized开始)
    3: dup
    4: astore_1 // lock引用 -> slot 1
    5: monitorenter // 将lock对象 MarkWord 置为 Monitor 指针
    6: getstatic #3 // <- i
    9: iconst_1 // 准备常数1
    10: iadd // +1
    11: putstatic #3 // -> i
    14: aload_1 // <- lock引用
    15: monitorexit // 将lock对象 MarkWord 重置,唤醒EntryList
    16: goto 24
    19: astore_2 // e -> slot2
    20: aload_1 // <- lock引用
    21: monitorexit // 将 lock 对象 MarkWord 重置,唤醒EntryList
    22: aload_2 // <- slot 2 (e)
    23: athrow // throw e
    24: return
    Exception table:
    from to target type
    6 16 19 any
    19 22 19 any
    LineNumberTable:
    line 8: 0
    line 9: 6
    line 10: 14
    line 11: 24
    LocalVar iableTable:
    Start Length Slot Name Signature
    0 25 0 args [Ljava/lang/String;
    StackMapTable: number_of_entries = 2
    frame_type = 255 /* full_ _frame */
    offset_delta = 19
    locals = [ class "[Ljava/lang/String;", class java/lang/object ]
    stack = [ class java/lang/Throwable ]
    frame_type = 250 /* chop */
    offset_delta = 4

注意:

  • 方法级别的 synchronized 不会在字节码指令中有所体现
  • 在字节码中的16: goto 24当中,执行到这里会跳转到第24行的字节码执行24:return返回
  • 那么第19行到第23行的字节码的作用是什么?
    • 仔细阅读字节码的内容会发现:他们的作用是当同步代码块中的内容出现异常的时候,为了防止当前的锁得不到释放而造成死锁,在第19到第23行进行异常的抛出锁的释放

关注字节码当中的monitorentermonitorexit即可。

MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

img

该图可以看出,任意线程对Object的访问,首先要获得Object的监视器monitor,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

2、可重入原理:加锁次数计数器

上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗? 答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁

Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

3、保证可见性的原理:内存模型和happens-before规则

Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。继续来看代码:

1
2
3
4
5
6
7
8
9
10
11
public class MonitorDemo {
private int a = 0;

public synchronized void writer() { // 1
a++; // 2
} // 3

public synchronized void reader() { // 4
int i = a; // 5
} // 6
}

该代码的happens-before关系如图所示:

img

在图中每一个箭头连接的两个节点就代表之间的happens-before关系:

  • 黑色的是通过程序顺序规则推导出来,
  • 红色的为监视器锁规则推导而出:
    • 线程A释放锁happens-before线程B加锁;
  • 蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。
    • 现在我们来重点关注:2 happens-before 5,通过这个关系我们可以得出什么?
      • 根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。

4、synchronized 是给对象加锁的原理——对象的对象头

synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头:我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:
    • Mark Word(标记字段)
    • Klass Pointer(类型指针)

以 32 位虚拟机为例:

普通对象:

1
2
3
4
5
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|

数组对象:

1
2
3
4
5
|---------------------------------------------------------------------------------| 
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|

其中 Mark Word 结构为:

1
2
3
4
5
6
7
8
9
10
11
12
13
|-------------------------------------------------------|--------------------| 
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | 00 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|

64 位虚拟机 Mark Word:

1
2
3
|--------------------------------------------------------------------|--------------------| |                        Mark Word (64 bits)                         |       State        | |--------------------------------------------------------------------|--------------------| | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal       | |--------------------------------------------------------------------|--------------------| | thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |       Biased       | |--------------------------------------------------------------------|--------------------| |             ptr_to_lock_record:62                          | 00    | Lightweight Locked | |--------------------------------------------------------------------|--------------------| |             ptr_to_heavyweight_monitor:62                  | 10    | Heavyweight Locked | |--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|

Monitor 被翻译为监视器管程

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针,如下图所示,右侧就是对象对应的 Monitor 对象。

图片

当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。

另外 Monitor 中还有两个队列分别是EntryListWaitList,主要是用来存放进入及等待获取锁的线程

如果线程进入,则得到当前对象锁,那么别的线程在该类所有对象上的任何操作都不能进行。

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor 结构如下

image-20210804223435390

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程
  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争

注意:

  • synchronized 必须是进入同一个对象的 monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

在对象级使用锁通常是一种比较粗糙的方法,为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?

如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。

由于每个对象都有锁,可以如下所示使用虚拟对象来上锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FineGrainLock{
MyMemberClass x,y;
Object xlock = new Object(), ylock = newObject();
public void foo(){
synchronized(xlock){
//accessxhere
}
//dosomethinghere-butdon'tusesharedresources
synchronized(ylock){
//accessyhere
}
}
public void bar(){
synchronized(this){
//accessbothxandyhere
}
//dosomethinghere-butdon'tusesharedresources
}
}

4、JVM中锁的优化

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)下,如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁
  • 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(栈上分配)(同时还可以减少Heap上的垃圾收集开销)。
  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 适应性自旋(Adaptive Spinning)当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态

1、锁的类型

在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁偏向锁轻量级所重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

2、自旋锁与自适应自旋锁

1、自旋锁

引入背景:

大家都知道,在没有加入锁优化时,Synchronized是一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但==不放弃CPU的执行时间==。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。

自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。

自旋重试成功的情况:

线程1 (core1上) 对象Mark 线程2 ( core2上)
- 10(重量锁) -
访问同步块,获取monitor 10 (重量锁)重量锁指针 -
成功(加锁) 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 访问同步块,获取monitor
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行完毕 10 (重量锁)重量锁指针 自旋重试
成功(解锁) 01(无锁) 自旋重试
- 10 (重量锁)重量锁指针 成功(加锁)
- 10 (重量锁)重量锁指针 执行同步块
- …… ……

自旋重试失败的情况:

线程1 (core1上) 对象Mark 线程2 ( core2上)
- 10(重量锁) -
访问同步块,获取monitor 10 (重量锁)重量锁指针 -
成功(加锁) 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 访问同步块,获取monitor
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 阻塞
- …… ……

可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

2、自适应自旋锁

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100次循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。

总结:

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能

3、锁消除

锁消除时指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。

当然在实际开发中,我们很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。

比如如下操作:在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK 1.5之前会使用StringBuffer对象(线程安全)的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象(线程不安全)的连续append()操作。

1
2
3
4
public static String test03(String s1, String s2, String s3) {
String s = s1 + s2 + s3;
return s;
}

对上述代码使用javap 编译的结果:

img

众所周知,StringBuilder不是安全同步的,但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)

4、锁粗化

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。

这里贴上根据上述Javap 编译地情况编写的实例java类

1
2
3
4
5
6
7
public static String test04(String s1, String s2, String s3) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

在上述地连续append()操作中就属于这类情况。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的外部,使整个一连串地append()操作只需要加锁一次就可以了。

5、轻量级锁

在JDK 1.6之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能

如果要理解轻量级锁,那么必须先要了解HotSpot虚拟机中对象头的内存布局。在对象头中(Object Header)存在两部分:(对象头的大小:(压缩指针)12字节,(不支持压缩指针)16字节)

  1. 第一部分用于存储对象自身的运行时数据HashCodeGC Age锁标记位是否为偏向锁。等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word它是实现轻量级锁和偏向锁的关键
  2. 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度
轻量级锁加锁

在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Ward)这个时候线程堆栈与对象头的状态如图:

img

image-20210804232649899

如上图所示:如果当前对象没有被锁定,那么锁标志位为01状态,JVM在执行当前线程时,首先会在当前线程栈帧中创建锁记录Lock Record的空间用于存储锁对象目前的Mark Word的拷贝。

image-20210804233935482

然后,虚拟机使用CAS操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word更新为指向Lock Record的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新为(Mark Word中最后的2bit)00,即表示此对象处于轻量级锁定状态,如图:

img

image-20210804233158129

如果这个更新操作失败:

  • JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。(可重入锁)
    • image-20210804233645213
  • 如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那**轻量级锁就不再有效,直接膨胀为重量级锁(锁膨胀)**,没有获得锁的线程会被阻塞。此时,锁的标志位为10Mark Word中存储的时指向重量级锁的指针。

轻量级解锁时:

  • 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
    • image-20210804234421675
  • 锁记录的值不为 null,这时会使用原子的CAS操作将Displaced Mark Word替换回到对象头中:
    • 如果成功,则表示没有发生竞争关系,解锁成功
    • 如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁,进入重量级锁的解锁流程

6、锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

1
2
3
4
5
6
static Object obj = new Object(); 
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
    • image-20210805013827060
  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
    • 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
    • 然后自己进入 Monitor 的 EntryList BLOCKED
    • image-20210805013917321
    • 此时Object的对象头的锁标志为10
  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

两个线程同时争夺锁,导致锁膨胀的流程图如下:

img

7、偏向锁

引入背景:

在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换

为了解决这一问题,HotSpot的作者在Java SE 1.6 中对Synchronized进行了优化,引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS操作来加锁和解锁。只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁

1、偏向状态

64 位虚拟机 Mark Word:

1
2
3
|--------------------------------------------------------------------|--------------------| |                        Mark Word (64 bits)                         |       State        | |--------------------------------------------------------------------|--------------------| | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal       | |--------------------------------------------------------------------|--------------------| | thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |       Biased       | |--------------------------------------------------------------------|--------------------| |             ptr_to_lock_record:62                          | 00    | Lightweight Locked | |--------------------------------------------------------------------|--------------------| |             ptr_to_heavyweight_monitor:62                  | 10    | Heavyweight Locked | |--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
    • 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中(54位的threadID)
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
  • 如果你想禁用偏向锁,添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁

img

2、偏向锁的撤销
1、方法一:调用对象的hashCode方法(对象仍可偏向)
  • 如果默认开启了偏向锁,但当调用了对象的hashCode方法则会破坏对象的偏向锁
    • 正常状态对象一开始是没有 hashCode 的,第一次调用才生成
    • 调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
      • 轻量级锁会在锁记录中记录 hashCode
      • 重量级锁会在 Monitor 中记录 hashCode
      • 偏向锁没有其它记录hashCode的方法,所以调用对象的hashCode会撤销对象的偏向锁
    • 在调用 hashCode 后使用偏向锁,记得去掉 -XX:-UseBiasedLocking(禁用偏向锁)
2、方法二:当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁(对象变为不可偏向)

演示代码:(加上了VM参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Slf4j(topic = "c.TestBiased")
public class TestBiased {
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();

new Thread(() -> {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (TestBiased.class) {
TestBiased.class.notify();
}
},"t1").start();

new Thread(() -> {
synchronized (TestBiased.class) {
try {
TestBiased.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
},"t2").start();
}
}

class Dog {

}

输出:

1
2
3
4
5
6
20:48:31.674 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
20:48:31.677 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.677 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.677 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.678 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00100000 01001011 11110011 00100000
20:48:31.678 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001

分析:

  • 由于t2线程使用了wait,所以t2需要t1线程的notify唤醒,所以t1线程肯定由于t2线程,得到偏向锁。然后唤醒t2线程后,t2线程去争夺锁,导致了t1线程的偏向锁的破坏,并且t1线程变为不可偏向。
  • 第一行:由于禁用延迟,所以t1线程一开始就处于101的偏向锁,只是此时t1线程还没得到锁,所以它的 thread、epoch、age 都为 0
  • 第二行:t1线程拿到了锁,Mark Word记录了当前线程的ThreadID(54位)、epoch(2位)、unused(1位)和age(4位)
  • 第三行:t1线程释放了锁,由于t1线程为偏向锁,所以Mark Word依旧记录了t1线程的ThreadID(54位)
  • 递四行:t1线程唤醒了t2线程,当此时t2x线程还没有抢夺t1线程的偏向锁,所以Mark Word没变
  • 第五行:t2线程抢夺t1的偏向锁,破坏了t1线程的偏向锁,偏向锁膨胀为轻量级锁(Mark Word后三位为000
    • 此时Mark Word记录的是ptr_to_lock_record:62
  • 第六行:t2线程释放锁,Mark Word后三位为001

底层:

偏向锁使用了一种==等待竞争出现才会释放锁==的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放偏向锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁

img

3、调用 wait/notify

因为 wait/notify(等待唤醒)模式是应用在重量级锁上的,所以调用 wait/notify就意味着此时是重量级锁,而不是偏向锁与轻量级锁。

3、批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID

当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private static void test3() throws InterruptedException {
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
synchronized (list) {
list.notify();
}
},"t1");
t1.start();

Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
log.debug("===============> ");
for (int i = 0; i < 30; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
},"t2");
t2.start();
}

class Dog {

}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
[t1] - 0 		00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 1 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 2 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 3 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 4 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 5 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 6 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 7 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 8 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 9 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 10 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 11 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 12 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 13 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 14 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 15 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 16 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 17 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 18 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - ===============>
[t2] - 0 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 0 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 0 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 1 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 1 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 1 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 2 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 2 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 2 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 3 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 3 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 3 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 4 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 4 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 4 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 5 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 5 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 5 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 6 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 6 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 6 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 7 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 7 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 7 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 8 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 8 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 8 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 9 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 9 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 9 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 10 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 10 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 10 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 11 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 11 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 11 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 12 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 12 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 12 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 13 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 13 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 13 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 14 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 14 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 14 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 15 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 15 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 15 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 16 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 16 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 16 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 17 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 17 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 17 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 18 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 18 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 18 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101

注意:

1
2
3
[t2] - 19		00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101

在第20次(从0开始,到19)后,批量重偏向

4、批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;

import java.util.Vector;
import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.TestBiased")
public class TestBiased {

static Thread t1,t2,t3;

public static void main(String[] args) throws InterruptedException {
test4();
}
private static void test4() throws InterruptedException {
Vector<Dog> list = new Vector<>();

int loopNumber = 39;
t1 = new Thread(() -> {
for (int i = 0; i < loopNumber; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
LockSupport.unpark(t2);
}, "t1");
t1.start();

t2 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
LockSupport.unpark(t3);
}, "t2");
t2.start();

t3 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}, "t3");
t3.start();

t3.join();
log.debug(ClassLayout.parseInstance(new Dog()).toPrintableSimple(true));
}
}

class Dog {

}

输出:

  • t1线程前面的39个对象全部拥有了偏向锁
    • image-20210805043937707
  • t2线程前19次因为破坏了t1线程对象的偏向锁,升级为轻量级锁
    • image-20210805044214898
  • t2线程从第20次后进入批量重偏向,从第20次到第39次全部都是批量重偏向,t2线程拥有偏向锁
    • image-20210805044307591
  • t3线程的前19个对象为轻量级锁(t2修改为轻量级锁)
    • image-20210805044616494
  • t3线程从第20个对象开始,此时对象的偏向锁是偏向t2线程的,所以t3线程会破坏t2线程的偏向锁,升级为轻量级锁,从第20个到第39个都是这样。
    • image-20210805044906263
  • 由于JVM进行了前39次的偏向锁撤销,在进行第40次撤销操作时,JVM会将整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
    • image-20210805045205698
  • 如果把loopNumber的值修改为38,即只进行38次偏向锁撤销,那么在第39次偏向锁撤销,JVM依旧会采用偏向锁升级为轻量级锁,此时的对象依旧是可偏向的(101
    • image-20210805045456551

8、锁的优缺点对比

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步快的场景
轻量级锁 竞争的线程不会阻塞,提高了响应速度 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 追求响应时间,同步快执行速度非常快
重量级锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步快执行速度较长

5、Synchronized与Lock

1、synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时;
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,….,如果获取失败,…..
  • 如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
    • 获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
    • 线程执行发生异常,此时 JVM 会让线程自动释放锁。
  • 那么如果这个获取锁的线程由于要等待 I/O 或者其他原因(比如调用 sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,试想一下,这多么影响程序执行效率。

2、Lock解决相应问题

Lock类这里不做过多解释,主要看里面的4个方法:

  • lock():加锁
  • unlock():解锁
  • tryLock():尝试获取锁,返回一个boolean值
  • tryLock(long,TimeUtil):尝试获取锁,可以设置超时

Synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合解决了这个问题。

多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。

Lock可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断)。

ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了

注:

  • ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大
  • JUC中的JUC锁:ReentrantLock

3、总结:Lock 与的 Synchronized 区别

  • Lock 不是 Java 语言内置的,synchronized 是 Java 语言的关键字,因此是内置特性。Lock 是一个类,通过这个类可以实现同步访问;
  • synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁;
  • Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用synchronized 时,等待的线程会一直等待下去,不能够响应中断;
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到;
  • Lock 可以提高多个线程进行读操作的效率。

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时 Lock 的性能要远远优于synchronized

6、再深入理解

synchronized是通过软件(JVM)实现的,简单易用,即使在JDK5之后有了Lock,仍然被广泛地使用。

  • 使用Synchronized有哪些要注意的?
    • 锁对象不能为空,因为锁的信息都保存在对象头里
    • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
    • 避免死锁
    • 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错
  • synchronized是公平锁吗?
    • synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待
    • 不过这种抢占的方式可以预防饥饿
  • 使用Synchronized可以解决可见性问题吗?
    • 可以在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存→在主内存中拷贝最新变量的副本到工作内存 →执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。

5、关键字:volatile详解

相比Sychronized(重量级锁,对系统性能影响较大),volatile提供了另一种解决可见性和有序性问题的方案。

1、BAT大厂的面试问题

  • volatile关键字的作用是什么?
  • volatile能保证原子性吗?
  • 之前32位机器上共享的long和double变量的为什么要用volatile?现在64位机器上是否也要设置呢?
  • i++为什么不能保证原子性?
  • volatile是如何实现可见性的?
    • 内存屏障
  • volatile是如何实现有序性的?
    • happens-before等
  • 说下volatile的应用场景?

2、volatile的作用详解

1、防重排序

我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
// 向外界通过一个getInstance()方法
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

  • 分配内存空间。
  • 初始化对象。
  • 将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  • 分配内存空间。
  • 将内存空间的地址赋值给对应的引用。
  • 初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

注意:加上volatile的变量会保证在它之前的指令不会被重排序。原因:在加上volatile的变量的地方会加上一个内存屏障,保证在它之前的指令不会重排序到它下面去。

2、实现可见性

可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题,我们看下面的例子,就可以知道其作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class VolatileTest {
int a = 1;
int b = 2;

public void change(){
a = 3;
b = a;
}

public void print(){
System.out.println("b=" + b + ";a=" + a);
}

public static void main(String[] args) {
while (true){
final VolatileTest test = new VolatileTest();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
}

直观上说,这段代码的结果只可能有两种:

  1. b=3;a=3
  2. b=2;a=1

不过运行上面的代码(可能时间上要长一点,概率要小很多),你会发现除了上两种结果之外,还出现了第三种结果:

  • b=3;a=1

分析:为什么会出现b=3;a=1这种结果呢?

  • 正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。
  • 相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。
  • 那b=3;a=1的结果是怎么出来的?
    • 原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。
    • 如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。

3、保证原子性:单次读/写

volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性

先从如下两个问题来理解(后文再从内存屏障的角度理解):

  1. 问题1: i++为什么不能保证原子性?
  2. 问题2: 共享的long和double变量的为什么要用volatile?
1、问题1: i++为什么不能保证原子性?

对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。

现在我们就通过下列程序来演示一下这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class VolatileTest01 {
volatile int i;

public void addI(){
i++;
}

public static void main(String[] args) throws InterruptedException {
final VolatileTest01 test01 = new VolatileTest01();
for (int n = 0; n < 1000; n++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test01.addI();
}
}).start();
}
Thread.sleep(10000);//等待10秒,保证上面程序执行完成
System.out.println(test01.i);
}
}

大家可能会误认为对变量i加上关键字volatile后,这段程序就是线程安全的。大家可以尝试运行上面的程序。下面是我本地运行的结果:981 可能每个人运行的结果不相同。不过应该能看出,volatile是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++其实是一个复合操作,包括三步骤:

  • 读取i的值。
  • 对i加1。
  • 将i的值写回内存。

i++的相关字节码指令:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
iadd //自增
putstatic i // 将修改后的值存入静态变量i

对于i–也是类似:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
isub //自减
putstatic i // 将修改后的值存入静态变量i

volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。

注:上面几段代码中多处执行了Thread.sleep()方法,目的是为了增加并发问题的产生几率,无其他作用。

2、问题2: 共享的long和double变量的为什么要用volatile?

因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。

如下是JLS中的解释:

17.7 Non-Atomic Treatment of double and long

  • For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
  • Writes and reads of volatile long and double values are always atomic.
  • Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
  • Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.
  • Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.

目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。

3、volatile的实现原理

1、volatile 可见性实现

volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现

  • 内存屏障,又称内存栅栏,是一个 CPU 指令。
  • 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入==特定类型的内存屏障来禁止==+ ==特定类型的编译器重排序和处理器重排序==**,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序**。
  • 对 volatile 变量的写指令后会加入写屏障
    • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
  • 对 volatile 变量的读指令前会加入读屏障
    • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

int num = 0;
volatile boolean ready = false;

public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready){
r1 = num + num;
} else {
r.r1 = 1;
}
}

public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
}

image-20210806223239737

写一段简单的 Java 代码,声明一个 volatile 变量,并赋值。

1
2
3
4
5
6
7
8
9
10
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}

通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......

lock 前缀的指令在多核处理器下会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。

如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值

volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值

1、lock 指令

在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证

2、缓存一致性

缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节

LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议

缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 “ 嗅探(snooping)" 协议

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效

2、volatile 有序性实现

1、volatile 的 happens-before 关系

happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//假设线程A执行writer方法,线程B执行reader方法
class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
a = 1; // 1 线程A修改共享变量
flag = true; // 2 线程A写volatile变量
}

public void reader() {
if (flag) { // 3 线程B读同一个volatile变量
int i = a; // 4 线程B读共享变量
……
}
}
}

根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。

  • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
  • 根据 volatile 规则:2 happens-before 3。
  • 根据 happens-before 的传递性规则:1 happens-before 4。

img

因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。

2、volatile 禁止重排序

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序

Java 编译器会在==生成指令系列==时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序

JMM 会针对编译器制定 volatile 重排序规则表:

img

“ NO “ 表示禁止重排序。

为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
内存屏障 说明
StoreStore 屏障 禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障 禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障 禁止下面所有的普通写操作和上面的 volatile 读重排序。

img

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

int num = 0;
volatile boolean ready = false;

public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready){
r1 = num + num;
} else {
r.r1 = 1;
}
}

public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
}

image-20210806223239737

还是那句话,不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

image-20210806224057799

3、double-checked locking 问题

以著名的 double-checked locking 单例模式为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private Singleton() {}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有
synchronized synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0: getstatic    	#2		// Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

其中:

  • 17 表示创建对象,将对象引用入栈 // new Singleton
  • 20 表示复制一份对象引用 // 引用地址
  • 21 表示利用一个对象引用,调用构造方法
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:

image-20210806224747694

关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

可能有人注意到:在synchronized内部的变量不是可以保证原子性、有序性和可见性吗?为什么21与24还会被重排序?

  • 被synchronized完全接管的变量确实可以保证原子性、有序性和可见性,但是必须是被synchronized完全接管的变量;
  • 在代码上INSTANCE并没有被synchronized完全接管,线程在synchronized内部使用INSTANCE的时候,在synchronized外部还是可能有其它线程接触INSTANCE
  • 所以在synchronized内部,INSTANCE还是有可能被重排序(24与21重排序)

解决方法:对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

4、double-checked locking 解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private Singleton() {}
private volatile static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有
synchronized synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

字节码上看不出来 volatile 指令的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 // -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter // -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit // ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  1. 可见性
    • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    • 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  2. 有序性
    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  3. 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

image-20210806225125974

4、volatile的应用场景

使用 volatile 必须具备的条件

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中
  • 只有在状态真正独立于程序内其他内容时才能使用 volatile

1、模式1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机

1
2
3
4
5
6
7
8
volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}

2、模式2:一次性安全发布(one-time safe publication)

缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BackgroundFloobleLoader {
public volatile Flooble theFlooble;

public void initInBackground() {
// do lots of stuff
theFlooble = new Flooble(); // this is the only write to theFlooble
}
}

public class SomeOtherClass {
public void doWork() {
while (true) {
// do some stuff...
// use the Flooble, but only if it is ready
if (floobleLoader.theFlooble != null)
doSomething(floobleLoader.theFlooble);
}
}
}

3、模式3:独立观察(independent observation)

安全使用 volatile 的另一种简单模式是定期发布观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserManager {
public volatile String lastUser;

public boolean authenticate(String user, String password) {
boolean valid = passwordIsValid(user, password);
if (valid) {
User u = new User();
activeUsers.add(u);
lastUser = user;
}
return valid;
}
}

4、模式4:volatile bean 模式

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@ThreadSafe
public class Person {
private volatile String firstName;
private volatile String lastName;
private volatile int age;

public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }

public void setFirstName(String firstName) {
this.firstName = firstName;
}

public void setLastName(String lastName) {
this.lastName = lastName;
}

public void setAge(int age) {
this.age = age;
}
}

5、模式5:开销较低的读-写锁策略

volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销

1
2
3
4
5
6
7
8
9
10
11
12
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;

public int getValue() { return value; }

public synchronized int increment() {
return value++;
}
}

6、模式6:双重检查(double-checked)

单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。


6、关键字:final详解

1、BAT大厂的面试问题

  • 所有的final修饰的字段都是编译期常量吗?
  • 如何理解private所修饰的方法是隐式的final?
  • 说说final类型的类如何拓展?比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?
  • final方法可以被重载吗?
    • 可以
  • 父类的final方法能不能够被子类重写?
    • 不可以
  • 说说final域重排序规则?
  • 说说final的原理?
  • 使用 final 的限制条件和局限性?
  • 看本文最后的一个思考题

2、final基础使用

1、修饰类

当某个类的整体定义为final时,就表明了你不能打算继承该类,而且也不允许别人这么做。即这个类是不能有子类的

注意:final类中的所有方法都隐式为final,因为无法覆盖他们,所以在final类中给任何方法添加final关键字是没有任何意义的

那么final类型的类如何拓展?

  • 比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?

设计模式中最重要的两种关系,一种是继承/实现;另外一种是组合关系。所以当遇到不能用继承的(final修饰的类),应该考虑用组合,如下代码大概写个组合实现的意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @pdai
*/
class MyString{

private String innerString;

// ...init & other methods

// 支持老的方法
public int length(){
return innerString.length(); // 通过innerString调用老的方法
}

// 添加新方法
public String toMyString(){
//...
}
}

2、修饰方法

  • private 方法是隐式的final

  • final方法是可以被重载的

1、private final

类中所有private方法都隐式地指定为final的,由于无法取用private方法,所以也就不能覆盖它。可以对private方法增添final关键字,但这样做并没有什么好处

看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Base {
private void test() {
}
}

public class Son extends Base{
public void test() {
}
public static void main(String[] args) {
Son son = new Son();
Base father = son;
//father.test();
}
}

Base和Son都有方法test(),但是这并不是一种覆盖,因为private所修饰的方法是隐式的final,也就是无法被继承,所以更不用说是覆盖了,在Son中的test()方法不过是属于Son的新成员罢了,Son进行向上转型得到father,但是father.test()是不可执行的,因为Base中的test方法是private的,无法被访问到。

2、final方法是可以被重载的

我们知道父类的final方法是不能够被子类重写的,那么final方法可以被重载吗?

答案是可以的,下面代码是正确的。

1
2
3
4
5
6
7
public class FinalExampleParent {
public final void test() {
}

public final void test(String str) {
}
}

3、修饰参数

Java允许在参数列表中以声明的方式将参数指明为final,这意味这你无法在方法中更改参数引用所指向的对象

这个特性主要用来向匿名内部类传递数据

4、修饰变量

1、所有final修饰的字段都是编译器常量吗?

现在来看编译期常量和非编译期常量,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test {
//编译期常量
final int i = 1;
final static int J = 1;
final int[] a = {1,2,3,4};
//非编译期常量
Random r = new Random();
final int k = r.nextInt();

public static void main(String[] args) {

}
}

k的值由随机数对象决定,所以不是所有的final修饰的字段都是编译期常量,只是k的值在被初始化后无法被更改。

2、static final

一个既是static又是final 的字段只占据一段不能改变的存储空间,它必须在定义的时候进行赋值,否则编译器将不予通过

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.Random;
public class Test {
static Random r = new Random();
final int k = r.nextInt(10);
static final int k2 = r.nextInt(10);
public static void main(String[] args) {
Test t1 = new Test();
System.out.println("k="+t1.k+" k2="+t1.k2);
Test t2 = new Test();
System.out.println("k="+t2.k+" k2="+t2.k2);
}
}

上面代码某次输出结果:

1
2
k=2 k2=7
k=8 k2=7

我们可以发现对于不同的对象k的值是不同的,但是k2的值却是相同的,这是为什么呢?

  • 因为static关键字所修饰的字段并不属于一个对象,而是属于这个类的。
  • 也可简单的理解为static final所修饰的字段仅占据内存的一个一份空间,一旦被初始化之后便不会被更改
3、blank final

Java允许生成空白final,也就是说被声明为final但又没有给出定值的字段,但是必须在该字段被使用之前被赋值,这给予我们两种选择:

  • 在定义处进行赋值(这不叫空白final)
  • 在构造器中进行赋值,保证了该值在被使用前赋值。

这增强了final的灵活性。

看下面代码:

1
2
3
4
5
6
7
8
9
10
public class Test {
final int i1 = 1;
final int i2;//空白final
public Test() {
i2 = 1;
}
public Test(int x) {
this.i2 = x;
}
}

可以看到i2的赋值更为灵活。

但是请注意,如果字段由static和final修饰,仅能在定义处赋值,因为该字段不属于对象,属于这个类。

3、final域重排序规则

上面final的使用,应该属于Java基础层面的,当理解这些后我们就真的算是掌握了final吗? 有考虑过final在多线程并发的情况吗?

在java内存模型中我们知道java内存模型为了能让处理器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说java内存模型就是弱内存数据模型。同时,处理器和编译为了性能优化会对指令序列有编译器和处理器重排序。

那么,在多线程情况下,final会进行怎样的重排序? 会导致线程安全的问题吗?

下面,就来看看final的重排序。

1、final域为基本类型

先看一段示例性的代码:(假设线程A在执行writer()方法,线程B执行reader()方法。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FinalDemo {
private int a; //普通域
private final int b; //final域
private static FinalDemo finalDemo;

public FinalDemo() {
a = 1; // 1. 写普通域
b = 2; // 2. 写final域
}

public static void writer() {
finalDemo = new FinalDemo();
}

public static void reader() {
FinalDemo demo = finalDemo; // 3.读对象引用
int a = demo.a; //4.读普通域
int b = demo.b; //5.读final域
}
}
1、写final域重排序规则

写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外;
  • 编译器会在final域写之后,构造函数return之前,插入一个storestore屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。

我们再来分析writer方法,虽然只有一行代码,但实际上做了两件事情:

  • 构造了一个FinalDemo对象;
  • 把这个对象赋值给成员变量finalDemo。

我们来画下存在的一种可能执行时序图,如下:

img

由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。

因此,写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo

2、读final域重排序规则

读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读final域操作的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。

read()方法主要包含了三个操作:

  • 初次读引用变量finalDemo;
  • 初次读引用变量finalDemo的普通域a;
  • 初次读引用变量finalDemo的final域b;

假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图:

img

读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用

2、final域为引用类型

1、对final域修饰的对象的成员域写操作

针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的

注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是使用。这句话是比较拗口的,下面结合实例来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;

public FinalReferenceDemo() {
arrays = new int[1]; //1
arrays[0] = 1; //2
}

public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo(); //3
}

public void writerTwo() {
arrays[0] = 2; //4
}

public void reader() {
if (finalReferenceDemo != null) { //5
int temp = finalReferenceDemo.arrays[0]; //6
}
}
}

针对上面的实例程序,线程线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。下图就以这种执行时序出现的一种情况来讨论。

img

由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序

2、对final域修饰的对象的成员域读操作

JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。

3、关于final重排序的总结

按照final修饰的数据类型分类:

  • 基本数据类型:
    • final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
    • final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
  • 引用数据类型:
    • 额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序

4、final再深入理解

1、final的实现原理

上面我们提到过,写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。读final域的重排序规则会要求编译器在读final域的操作前插入一个LoadLoad屏障。

很有意思的是,如果以X86处理为例,X86不会对写-写重排序,所以StoreStore屏障可以省略。由于不会对有间接依赖性的操作重排序,所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说,以X86为例的话,对final域的读/写的内存屏障都会被省略!具体是否插入还是得看是什么处理器

1、设置 final 变量的原理:
1
2
3
public class TestFinal {
final int a = 20;
}

字节码:

1
2
3
4
5
6
7
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0 20
5: bipush #2 // Field a:I
7: putfield
<-- 写屏障
10: return

发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障,保证在其它线程读到它的值时不会出现为 0 的情况。

2、获取 final 变量的原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class TestFinal {
static int A = 10;
final static int B = Short.MAX_VALUE+1;

final int a = 20;
final int b = Integer.MAX_VALUE;

final void test1() {
final int c = 30;
new Thread(()->{
System.out.println(c);
}).start();

final int d = 30;
class Task implements Runnable {

@Override
public void run() {
System.out.println(d);
}
}
new Thread(new Task()).start();
}

}

class UseFinal1 {
public void test() {
System.out.println(TestFinal.A);
System.out.println(TestFinal.B);
System.out.println(new TestFinal().a);
System.out.println(new TestFinal().b);
new TestFinal().test1();
}
}

class UseFinal2 {
public void test() {
System.out.println(TestFinal.A);
}
}

结果:

1

分析:

  • 如果final static int A = 10;加入final的话:那么在字节码的层面可以看到:BIPUSH 10,即在读取A的时候,它是从栈中直接复制了一个10给到了A,走的不是共享这条路(没有从其他类中读取数据)
    • image-20210810231107766
  • 如果static int A = 10;没有加final的话:那么在字节码的层面可以看到:GETSTATIC cn/itcast/n5/TestFinal.A : I,即在读取A的时候,它是从TestFinal类中获取的到的10,走的是共享这条路(从其他类中读取数据),那么A就是==共享内存==,性能比==栈内存==要低。
    • image-20210810231345476
  • 对于final static int B = Short.MAX_VALUE+1;来说,B是Short的最大值在加上1(超过了极限)。加入final的话:那么在字节码的层面可以看到:LDC 32768(Short的最大值是32767),即读取的是常量池当中的内容,同理也没有走共享内存这条路(没有从其他类中读取数据)
    • image-20210810232209213
  • 如果B没有加final的话:那么在字节码的层面可以看到:GETSTATIC cn/itcast/n5/TestFinal.B : I,即在读取B的时候,它是从TestFinal类中获取的,走的是共享这条路(从其他类中读取数据)
    • image-20210810232531747
  • 下面的成员变量ab也是同样的道理:加入final修饰的,在引用到a/b的时候,会复制一份到调用方的常量池当中,直接从栈内存获取就行(==栈内存==),效率高。没有加final修饰的,在引用到a/b的时候,会直接到类中获取(==共享内存==),效率比较低。
  • 总结:一个final修饰的基本变量可以完全等价于一个常量,整个jvm实例生命周期内都不会变化了,这个值在编译时就已经写死成直接引用了

2、为什么final引用不能从构造函数中”溢出”

这里还有一个比较有意思的问题:上面对final域写重排序规则可以确保我们在使用一个对象引用的时候该对象的final域已经在构造函数被初始化过了。

但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中“溢出”。以下面的例子来说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FinalReferenceEscapeDemo {
private final int a;
private FinalReferenceEscapeDemo referenceDemo;

public FinalReferenceEscapeDemo() {
a = 1; //1
referenceDemo = this; //2
}

public void writer() {
new FinalReferenceEscapeDemo();
}

public void reader() {
if (referenceDemo != null) { //3
int temp = referenceDemo.a; //4
}
}
}

可能的执行时序如图所示:

img

假设一个线程A执行writer方法另一个线程执行reader方法。因为构造函数中操作1和2之间没有数据依赖性,1和2可以重排序,先执行了2,这个时候引用对象referenceDemo是个没有完全初始化的对象,而当线程B去读取该对象时就会出错。尽管依然满足了final域写重排序规则:在引用对象对所有线程可见时,其final域已经完全初始化成功。但是,引用对象“this”逸出,该代码依然存在线程安全的问题。

3、使用final的限制条件和局限性

  • 当声明一个 final 成员时,必须在构造函数退出前设置它的值。

    1
    2
    3
    4
    5
    6
    public class MyClass {
    private final int myField = 1;
    public MyClass() {
    ...
    }
    }
    • 或者
    1
    2
    3
    4
    5
    6
    7
    8
    public class MyClass {
    private final int myField;
    public MyClass() {
    ...
    myField = 1;
    ...
    }
    }
  • 将指向对象的成员声明为 final 只能将该引用设为不可变的,而非所指的对象。

    • 下面的方法仍然可以修改该 list。

      1
      2
      private final List myList = new ArrayList();
      myList.add("Hello");
    • 声明为 final 可以保证如下操作不合法

      1
      2
      myList = new ArrayList();
      myList = someOtherList;
  • 如果一个对象将会在多个线程中访问并且你并没有将其成员声明为 final,则必须提供其他方式保证线程安全。

    • “ 其他方式 “ 可以包括声明成员为 volatile,使用 synchronized 或者显式 Lock 控制所有该成员的访问。

4、再思考一个有趣的现象

1
2
3
byte b1=1;
byte b2=3;
byte b3=b1+b2;//当程序执行到这一行的时候会出错,因为b1、b2可以自动转换成int类型的变量,运算时java虚拟机对它进行了转换,结果导致把一个int赋值给byte-----出错

如果对b1 b2加上final就不会出错:

1
2
3
final byte b1=1;
final byte b2=3;
byte b3=b1+b2;//不会出错,相信你看了上面的解释就知道原因了。

7、JUC(java.util.concurrent)

0、JUC - 类汇总和学习总览

1、BAT大厂的面试问题

  • JUC框架包含几个部分?
  • 每个部分有哪些核心的类?
  • 最最核心的类有哪些?

2、Overview

JUC相关的五大类与框架:

image

主要包含: (注意: 上图是网上找的图,无法表述一些继承关系,同时少了部分类;但是主体上可以看出其分类关系也够了)

  • Lock框架和Tools类(把图中这两个放到一起理解)
  • Collections: 并发集合
  • Atomic: 原子类
  • Executors: 线程池

3、相关类与框架

1、Lock框架和Tools类

类结构总览:

image

  • 接口
    • Condition
      • Condition为接口类型,它将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set (wait-set)。
      • 其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。可以通过await(),signal()来休眠/唤醒线程。
    • Lock
      • Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象
    • ReadWriteLock
      • ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。
  • 抽象类
    • AbstractOwnableSynchonizer
      • AbstractOwnableSynchonizer为抽象类,可以由线程以独占方式拥有的同步器。
      • 此类为创建锁和相关同步器(伴随着所有权的概念)提供了基础。
      • AbstractOwnableSynchronizer 类本身不管理或使用此信息。但是,子类和工具可以使用适当维护的值帮助控制和监视访问以及提供诊断。
    • (Long)AbstractQueuedLongSynchonizer
      • AbstractQueuedLongSynchronizer为抽象类,以 long 形式维护同步状态的一个 AbstractQueuedSynchronizer 版本。
      • 此类具有的结构、属性和方法与 AbstractQueuedSynchronizer 完全相同,但所有与状态相关的参数和结果都定义为 long 而不是 int。
      • 当创建需要 64 位状态的多级别锁和屏障等同步器时,此类很有用。
    • 核心抽象类(int):AbstractQueuedSynchonizer
      • AbstractQueuedSynchonizer为抽象类,其为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件,等等)提供一个框架。
      • 此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。
  • 锁常用类
    • LockSupport
      • LockSupport为常用类,用来创建锁和其他同步类的基本线程阻塞原语。
      • LockSupport的功能和”Thread中的 Thread.suspend()和Thread.resume()有点类似”,LockSupport中的park() 和 unpark() 的作用分别是阻塞线程和解除阻塞线程。
      • 但是park()和unpark()不会遇到“Thread.suspend 和 Thread.resume所可能引发的死锁”问题。
    • ReentrantLock
      • ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。
    • ReentrantReadWriteLock
      • ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁。
    • StampedLock
      • 它是java8在java.util.concurrent.locks新增的一个API。
      • StampedLock控制锁有三种模式(写,读,乐观读),一个StampedLock状态是由版本和模式两个部分组成,锁获取方法返回一个数字作为票据stamp,它用相应的锁状态表示并控制访问,数字0表示没有写锁被授权访问。在读锁上分为悲观锁和乐观锁。
  • 工具常用类
    • CountDownLatch
      • CountDownLatch为常用类,它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。
    • CyclicBarrier
      • CyclicBarrier为常用类,其是一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。
      • 在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环 的 barrier。
    • Phaser
      • Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。
    • Semaphore
      • Semaphore为常用类,其是一个计数信号量,从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release() 添加一个许可,从而可能释放一个正在阻塞的获取者。
      • 但是,不使用实际的许可对象,Semaphore 只对可用许可的号码进行计数,并采取相应的行动。通常用于限制可以访问某些资源(物理或逻辑的)的线程数目。
    • Exchanger
      • Exchanger是用于线程协作的工具类,主要用于两个线程之间的数据交换。
      • 它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
2、Collections:并发集合

类结构关系:

image

  • Queue
    • ArrayBlockingQueue
      • 一个由数组支持的有界阻塞队列。此队列按 FIFO(先进先出)原则对元素进行排序。
      • 队列的头部是在队列中存在时间最长的元素。队列的尾部是在队列中存在时间最短的元素。
      • 新元素插入到队列的尾部,队列获取操作则是从队列头部开始获得元素。
    • LinkedBlockingQueue
      • 一个基于已链接节点的、范围任意的 blocking queue。此队列按 FIFO(先进先出)排序元素。
      • 队列的头部是在队列中时间最长的元素。队列的尾部是在队列中时间最短的元素。
      • 新元素插入到队列的尾部,并且队列获取操作会获得位于队列头部的元素。
      • 链接队列的吞吐量通常要高于基于数组的队列,但是在大多数并发应用程序中,其可预知的性能要低。
    • LinkedBlockingDeque
      • 一个基于已链接节点的、任选范围的阻塞双端队列。
    • ConcurrentLinkedQueue
      • 一个基于链接节点的无界线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序。
      • 队列的头部是队列中时间最长的元素。队列的尾部是队列中时间最短的元素。
      • 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。
      • 当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。
    • ConcurrentLinkedDeque
      • 是双向链表实现的无界队列,该队列同时支持FIFO和FILO两种操作方式。
    • DelayQueue
      • 延时无界阻塞队列,使用Lock机制实现并发访问。
      • 队列里只允许放可以“延期”的元素,队列中的head是最先“到期”的元素。
      • 如果队里中没有元素到“到期”,那么就算队列中有元素也不能获取到。
    • PriorityBlockingQueue
      • 无界优先级阻塞队列,使用Lock机制实现并发访问。
      • priorityQueue的线程安全版,不允许存放null值,依赖于comparable的排序,不允许存放不可比较的对象类型。
    • SynchronousQueue
      • 没有容量的同步队列,通过CAS实现并发访问,支持FIFO和FILO。
    • LinkedTransferQueue
      • JDK 7新增,单向链表实现的无界阻塞队列,通过CAS实现并发访问,队列元素使用 FIFO(先进先出)方式。
      • LinkedTransferQueue可以说是ConcurrentLinkedQueue、SynchronousQueue(公平模式)和LinkedBlockingQueue的超集,它不仅仅综合了这几个类的功能,同时也提供了更高效的实现。
  • List
    • CopyOnWriteArrayList
      • ArrayList 的一个线程安全的变体,其中所有可变操作(add、set 等等)都是通过对底层数组进行一次新的复制来实现的。
      • 这一般需要很大的开销,但是当遍历操作的数量大大超过可变操作的数量时,这种方法可能比其他替代方法更有效。在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时,它也很有用。
  • Set
    • CopyOnWriteArraySet
      • 对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。
      • 在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList。
    • ConcurrentSkipListSet
      • 一个基于ConcurrentSkipListMap 的可缩放并发 NavigableSet 实现。set 的元素可以根据它们的自然顺序进行排序,也可以根据创建 set 时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
  • Map
    • ConcurrentHashMap
      • 是线程安全HashMap的。ConcurrentHashMap在JDK 7之前是通过Lock和segment(分段锁)实现,JDK 8 之后改为CAS+synchronized来保证并发安全。
    • ConcurrentSkipListMap
      • 线程安全的有序的哈希表(相当于线程安全的TreeMap);映射可以根据键的自然顺序进行排序,也可以根据创建映射时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
3、Atomic: 原子类

其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,不会阻塞线程(或者说只是在硬件级别上阻塞了)。

  • 基础类型
    • AtomicBoolean
      • 针对bool的原子类。
    • AtomicInteger
      • 针对interger的原子类。
    • AtomicLong
      • 针对long的原子类。
  • 数组
    • AtomicIntegerArray
    • AtomicLongArray
    • BooleanArray
  • 引用
    • AtomicReference
    • AtomicMarkedReference
    • AtomicStampedReference
  • FieldUpdater
    • AtomicLongFieldUpdater
    • AtomicIntegerFieldUpdater
    • AtomicReferenceFieldUpdater
4、Executors:线程池

类结构关系:

img

  • 接口:Executor
    • Executor接口提供一种将任务提交与每个任务将如何运行的机制(包括线程使用的细节、调度等)分离开来的方法。通常使用 Executor 而不是显式地创建线程。
  • ExecutorService
    • ExecutorService继承自Executor接口,ExecutorService提供了管理终止的方法,以及可为跟踪一个或多个异步任务执行状况而生成 Future 的方法。 可以关闭 ExecutorService,这将导致其停止接受新任务。关闭后,执行程序将最后终止,这时没有任务在执行,也没有任务在等待执行,并且无法提交新任务。
  • ScheduledExecutorService
    • ScheduledExecutorService继承自ExecutorService接口,可安排在给定的延迟后运行或定期执行的命令。
  • AbstractExecutorService
    • AbstractExecutorService继承自ExecutorService接口,其提供 ExecutorService 执行方法的默认实现。此类使用 newTaskFor 返回的 RunnableFuture 实现 submit、invokeAny 和 invokeAll 方法,默认情况下,RunnableFuture 是此包中提供的 FutureTask 类。
  • FutureTask
    • FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务(cancel)等。
    • 如果任务尚未完成,获取任务执行结果时将会阻塞。一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。
    • FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。
    • 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。
    • FutureTask 的线程安全由CAS来保证。
  • 核心
    • ThreadPoolExecutor
      • ThreadPoolExecutor实现了AbstractExecutorService接口,也是一个 ExecutorService,它使用可能的几个池线程之一执行每个提交的任务,通常使用 Executors 工厂方法配置。
      • 线程池可以解决两个不同问题:由于减少了每个任务调用的开销,它们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行任务集时使用的线程)的方法。每个 ThreadPoolExecutor 还维护着一些基本的统计数据,如完成的任务数。
    • ScheduledThreadExecutor
      • ScheduledThreadPoolExecutor实现ScheduledExecutorService接口,可安排在给定的延迟后运行命令,或者定期执行命令。需要多个辅助线程时,或者要求 ThreadPoolExecutor 具有额外的灵活性或功能时,此类要优于 Timer。
    • Fork/Join框架
      • ForkJoinPool 是JDK 7加入的一个线程池类。
      • Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。
      • 目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。
  • 工具类:Executors
    • Executors是一个工具类,用其可以创建ExecutorService、ScheduledExecutorService、ThreadFactory、Callable等对象。
    • 它的使用融入到了ThreadPoolExecutor、ScheduledThreadExecutor和ForkJoinPool中。

1、JUC概述

1、什么是JUC

在 Java 中,线程部分是一个重点,本篇文章说的 JUC 也是关于线程的。JUC就是 java.util .concurrent 工具包的简称。这是一个处理线程的工具包,JDK 1.5 开始出现的。

image-20210721220607339

2、多线程编程步骤

  1. 第一:创建资源类,创建属性和操作方法
  2. 第二:在资源类的操作方法中
    1. 判断(使用while,不使用if,或者会出现虚假唤醒问题)
    2. 干活
    3. 通知
  3. 第三:创建多线程调用资源类的方法
  4. 第四:防止出现虚假唤醒问题
虚假唤醒问题
1、什么是虚假唤醒问题?

当一个条件满足时,很多线程都被唤醒了,但是只有其中部分是有用的唤醒,其它的唤醒都是无用的唤醒;这些无用的唤醒会导致出现一些问题。

2、为什么会出现虚假唤醒问题?

在多线程编程步骤的第二步中的判断中,如果使用的是if语句的话,就会出现虚假唤醒问题。原因:

  • if语句块只会判断一次
  • wait()方法的特性:在哪里等待,就在哪里开始

在官方文档中就明确规定要**==使用while语句块==,不要使用if语句块**:(因为while语句块可以不断的进行判断)

在这里插入图片描述

3、示例(参考下面进程间通信的代码——将其中的while修改为if,并运行发现)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
A=>1
B=>0
A=>1
B=>0
A=>1
B=>0
C=>1
A=>2
C=>3
B=>2
B=>1
B=>0
C=>1
A=>2
C=>3
B=>2
D=>1
D=>0
D=>-1
C=>0
C=>1
A=>2
C=>3
D=>2
D=>1
D=>0
C=>1
A=>2
C=>3
D=>2
D=>1
D=>0
C=>1
D=>0
C=>1
D=>0
4、分析(假设一开始为0)
  • 调用A –> 1
  • 调用C –> 1 C wait
  • 如果再调用A,那么A也会wait,A wait –> 1
  • 再调用B减1后, –> 0
  • 唤醒了A和C,执行A(C没抢到), –> 1
  • A执行完后,C抢到了CPU,此时C没有再进行判断,直接执行+1操作, –> 2
5、使用wait/notify的正确姿势——防止虚假唤醒问题
1
2
3
4
5
6
7
8
9
10
11
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}

//另一个线程
synchronized(lock) {
lock.notifyAll();
}

2、JUC原子类:CAS,Unsafe和原子类详解

JUC中多数类是通过volatile和CAS来实现的,CAS本质上提供的是一种无锁方案,而Synchronized和Lock是互斥锁方案;java原子类本质上使用的是CAS,而CAS底层是通过Unsafe类实现的。

1、BAT大厂的面试问题

  • 线程安全的实现方法有哪些?
  • 什么是CAS?
  • CAS使用示例,结合AtomicInteger给出示例?
  • CAS会有哪些问题?
  • 针对这这些问题,Java提供了哪几个解决的?
  • AtomicInteger底层实现?
    • CAS + volatile
  • 请阐述你对Unsafe类的理解?
  • 说说你对Java原子类的理解?包含13个,4组分类,说说作用和使用场景。
  • AtomicStampedReference是什么?
  • AtomicStampedReference是怎么解决ABA的?
    • 内部使用Pair来存储元素值及其版本号
  • java中还有哪些类可以解决ABA的问题?
    • AtomicMarkableReference

2、CAS

前面我们说到,线程安全的实现方法包含:

  • 互斥同步:synchronizedReentrantLock
  • 非阻塞同步:CASAtomicXXXX
  • 无同步方案:栈封闭Thread Local可重入代码
1、什么是CAS

CAS的全称为Compare-And-Swap,直译就是对比交换。是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。

简单解释:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。

CAS操作是原子性的,所以多线程并发使用CAS更新数据时,可以不使用锁。JDK中大量使用了CAS来更新数据而防止加锁(synchronized 重量级锁)来保持原子更新。

相信sql大家都熟悉,类似sql中的条件更新一样:update set id=3 from table where id=2。因为单条sql执行具有原子性,如果有多个线程同时执行此sql语句,只有一条能更新成功。但如果有多条sql的话,要保证操作的原子性,就要使用事务了。

2、CAS使用实例

如果不使用CAS,在高并发下,多线程同时修改一个变量的值我们需要synchronized加锁(可能有人说可以用Lock加锁,Lock底层的AQS也是基于CAS进行获取锁的)。

1
2
3
4
5
6
public class Test {
private int i = 0;
public synchronized int add(){
return i++;
}
}

java中为我们提供了AtomicInteger 原子类(底层基于CAS进行更新数据的),不需要加锁就在多线程并发场景下实现数据的一致性。

1
2
3
4
5
6
public class Test {
private AtomicInteger i = new AtomicInteger(0);
public int add(){
return i.addAndGet(1);
}
}
3、为什么无锁效率高
  • 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
  • 打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大
  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
4、CAS 的特点

结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
  • CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思:
    • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
5、CAS问题

CAS 方式为乐观锁,synchronized 为悲观锁。因此使用 CAS 解决并发问题通常情况下性能更优。

但使用 CAS 方式也会有几个问题:

  • ABA问题
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作
1、ABA问题

因为CAS需要在操作值的时候,检查值有没有发生变化,比如没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时则会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。

从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值

2、循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。

pause指令有两个作用:

  1. 第一,它可以延迟流水线执行命令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
  2. 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3、只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。

还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i = 2,j = a,合并一下ij = 2a,然后用CAS来操作ij。

从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作

3、Unsafe类详解

Java原子类是通过UnSafe类实现的,UnSafe类在J.U.C中CAS操作有很广泛的应用。

Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。

由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。

这个类尽管里面的方法都是 public 的,但是并没有办法使用它们,JDK API 文档也没有提供任何关于这个类的方法的解释。总而言之,对于 Unsafe 类的使用都是受限制的,只有授信的代码才能获得该类的实例,当然 JDK 库里面的类是可以随意使用的。

Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 通过反射获取一个unsafe对象
public class UnsafeAccessor {
static Unsafe unsafe;
static {
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
throw new Error(e);
}
}

static Unsafe getUnsafe() {
return unsafe;
}
}

先来看下这张图,对UnSafe类总体功能:

img

如上图所示,Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统信息获取、内存屏障、数组操作等几类。

1、Unsafe与CAS

反编译出来的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public final int getAndAddInt(Object paramObject, long paramLong, int paramInt)
{
int i;
do
i = getIntVolatile(paramObject, paramLong);
while (!compareAndSwapInt(paramObject, paramLong, i, i + paramInt));
return i;
}

public final long getAndAddLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
l = getLongVolatile(paramObject, paramLong1);
while (!compareAndSwapLong(paramObject, paramLong1, l, l + paramLong2));
return l;
}

public final int getAndSetInt(Object paramObject, long paramLong, int paramInt)
{
int i;
do
i = getIntVolatile(paramObject, paramLong);
while (!compareAndSwapInt(paramObject, paramLong, i, paramInt));
return i;
}

public final long getAndSetLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
l = getLongVolatile(paramObject, paramLong1);
while (!compareAndSwapLong(paramObject, paramLong1, l, paramLong2));
return l;
}

public final Object getAndSetObject(Object paramObject1, long paramLong, Object paramObject2)
{
Object localObject;
do
localObject = getObjectVolatile(paramObject1, paramLong);
while (!compareAndSwapObject(paramObject1, paramLong, localObject, paramObject2));
return localObject;
}

从源码中发现,**内部使用自旋的方式进行CAS更新(while循环进行CAS更新,如果更新失败,则循环再次重试)**。

又从Unsafe类中发现,原子操作其实只支持下面3种CAS方法:(都是native方法)

  1. compareAndSwapObject
  2. compareAndSwapInt
  3. compareAndSwapLong
1
2
3
4
5
public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);

public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);

public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);

三个方法都是有四个参数,这四个参数的含义都是一样的,分别是:(以compareAndSwapInt为例)

  • Object paramObject:操作的对象
  • long paramLong:操作对象的操作域的偏移地址
  • int paramInt1:原值
  • int paramInt2:修改值
2、使用unsafe

我们使用反射得到的unsafe来完成一些操作

1、使用unsafe去修改一个对象的字段(域)的取值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import lombok.Data;
import sun.misc.Unsafe;

import java.lang.reflect.Field;

public class TestUnsafe {

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);

// System.out.println(unsafe);

// 1. 获取域的偏移地址
long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id"));
long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name"));

Teacher t = new Teacher();
// 2. 执行 cas 操作
unsafe.compareAndSwapInt(t, idOffset, 0, 1);
unsafe.compareAndSwapObject(t, nameOffset, null, "张三");

// 3. 验证
System.out.println(t);
}
}
@Data
class Teacher {
volatile int id;
volatile String name;
}

输出:

1
Theater{id=1,name="张三"}
2、使用unsafe模拟实现原值整数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import cn.itcast.n4.UnsafeAccessor;
import lombok.extern.slf4j.Slf4j;
import sun.misc.Unsafe;

@Slf4j(topic = "c.Test42")
public class Test42 {
public static void main(String[] args) {
Account.demo(new MyAtomicInteger(10000));
}
}

class MyAtomicInteger implements Account {
private volatile int value;
private static final long valueOffset;
private static final Unsafe UNSAFE;
static {
UNSAFE = UnsafeAccessor.getUnsafe();
try {
valueOffset = UNSAFE.objectFieldOffset(MyAtomicInteger.class.getDeclaredField("value"));
} catch (NoSuchFieldException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}

public int getValue() {
return value;
}

public void decrement(int amount) {
while(true) {
int prev = this.value;
int next = prev - amount;
if (UNSAFE.compareAndSwapInt(this, valueOffset, prev, next)) {
break;
}
}
}

public MyAtomicInteger(int value) {
this.value = value;
}

@Override
public Integer getBalance() {
return getValue();
}

@Override
public void withdraw(Integer amount) {
decrement(amount);
}
}
2、Unsafe底层

Unsafe的compareAndSwap方法来实现CAS操作,它是一个本地方法,实现位于unsafe.cpp中。

1
2
3
4
5
6
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

可以看到它通过 Atomic::cmpxchg 来实现比较和替换操作。其中参数x是即将更新的值,参数e是原内存的值。

如果是Linux的x86,Atomic::cmpxchg方法的实现如下:

1
2
3
4
5
6
7
8
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::is_MP();
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}

而windows的x86的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::isMP(); //判断是否是多处理器
_asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:

如果是多处理器,为cmpxchg指令添加lock前缀。反之,就省略lock前缀(单处理器会不需要lock前缀提供的内存屏障效果)。这里的lock前缀就是使用了处理器的总线锁(最新的处理器都使用缓存锁代替总线锁来提高性能)。

cmpxchg(void* ptr, int old, int new),如果ptr和old的值一样,则把new写到ptr内存,否则返回ptr的值,整个操作是原子的。在Intel平台下,会用lock cmpxchg来实现,使用lock触发缓存锁,这样另一个线程想访问ptr的内存,就会被block住。

3、Unsafe其他功能

Unsafe 提供了硬件级别的操作,比如说获取某个属性在内存中的位置,比如说修改对象的字段值,即使它是私有的。不过 Java 本身就是为了屏蔽底层的差异,对于一般的开发而言也很少会有这样的需求。

举两个例子,比方说:这个方法可以用来获取给定的 paramField 的内存地址偏移量,这个值对于给定的 field 是唯一的且是固定不变的。

1
public native long staticFieldOffset(Field paramField);

再比如说:前一个方法是用来获取数组第一个元素的偏移地址,后一个方法是用来获取数组的转换因子即数组中元素的增量地址的。

1
2
public native int arrayBaseOffset(Class paramClass);
public native int arrayIndexScale(Class paramClass);

最后看三个方法:分别用来分配内存,扩充内存和释放内存的。

1
2
3
public native long allocateMemory(long paramLong);
public native long reallocateMemory(long paramLong1, long paramLong2);
public native void freeMemory(long paramLong);

更多相关功能,推荐你看下这篇文章:来自美团技术团队:Java魔法类:Unsafe应用解析

4、AutomicIntrger

1、使用举例

以 AtomicInteger 为例,常用 API:

1
2
3
4
5
6
public final int get():获取当前的值
public final int getAndSet(int newValue):获取当前的值,并设置新的值
public final int getAndIncrement():获取当前的值,并自增
public final int getAndDecrement():获取当前的值,并自减
public final int getAndAdd(int delta):获取当前的值,并加上预期的值
void lazySet(int newValue): 最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

相比 Integer 的优势,多线程中让变量自增:

1
2
3
4
5
6
7
8
private volatile int count = 0;
// 若要线程安全执行执行 count++,需要加锁
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}

使用 AtomicInteger 后:

1
2
3
4
5
6
7
8
private AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
// 使用 AtomicInteger 后,不需要加锁,也可以实现线程安全
public int getCount() {
return count.get();
}
2、源码解析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
//用于获取value字段相对当前对象的“起始地址”的偏移量
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

//返回当前值
public final int get() {
return value;
}

//递增加detla
public final int getAndAdd(int delta) {
//三个参数,1、当前的实例 2、value实例变量的偏移量 3、当前value要加上的数(value+delta)。
return unsafe.getAndAddInt(this, valueOffset, delta);
}

//递增加1
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// ...
}

我们可以看到 AtomicInteger 底层用的是volatile的变量和CAS来进行更改数据的。

  • volatile保证线程的可见性,多线程并发时,一个线程修改数据,可以保证其它线程立马看到修改后的值
  • CAS 保证数据更新的原子性。

5、延伸到所有原子类:共13个

JDK中提供了13个原子操作类。

1、原子更新基本类型

使用原子的方式更新基本类型,Atomic包提供了以下3个类。

  • AtomicBoolean:原子更新布尔类型。
  • AtomicInteger:原子更新整型。
  • AtomicLong:原子更新长整型。

以上3个类提供的方法几乎一模一样,可以参考上面AtomicInteger中的相关方法。

其它方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
AtomicInteger i = new AtomicInteger(0);
// 获取并自增(i = 0, 结果 i = 1, 返回 0),类似于 i++
System.out.println(i.getAndIncrement());
// 自增并获取(i = 1, 结果 i = 2, 返回 2),类似于 ++i
System.out.println(i.incrementAndGet());
// 自减并获取(i = 2, 结果 i = 1, 返回 1),类似于 --i
System.out.println(i.decrementAndGet());
// 获取并自减(i = 1, 结果 i = 0, 返回 1),类似于 i--
System.out.println(i.getAndDecrement());
// 获取并加值(i = 0, 结果 i = 5, 返回 0)
System.out.println(i.getAndAdd(5));
// 加值并获取(i = 5, 结果 i = 0, 返回 0)
System.out.println(i.addAndGet(-5));
// 获取并更新(i = 0, p 为 i 的当前值, 结果 i = -2, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.getAndUpdate(p -> p - 2));
// 更新并获取(i = -2, p 为 i 的当前值, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.updateAndGet(p -> p + 2));
// 获取并计算(i = 0, p 为 i 的当前值, x 为参数1, 结果 i = 10, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
// getAndUpdate 如果在 lambda 中引用了外部的局部变量,要保证该局部变量是 final 的
// getAndAccumulate 可以通过 参数1 来引用外部的局部变量,但因为其不在 lambda 中因此不必是 final
System.out.println(i.getAndAccumulate(10, (p, x) -> p + x));
// 计算并获取(i = 10, p 为 i 的当前值, x 为参数1, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.accumulateAndGet(-10, (p, x) -> p + x));
2、原子更新数组

通过原子的方式更新数组里的某个元素,Atomic包提供了以下的4个类:

  • AtomicIntegerArray:原子更新整型数组里的元素。

  • AtomicLongArray:原子更新长整型数组里的元素。

  • AtomicReferenceArray:原子更新引用类型数组里的元素。

    这三个类的最常用的方法是如下两个方法:

  • get(int index):获取索引为index的元素值。

  • compareAndSet(int i, E expect, E update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置为update值。

举个AtomicIntegerArray例子:

1
2
3
4
5
6
7
8
9
10
import java.util.concurrent.atomic.AtomicIntegerArray;

public class Demo5 {
public static void main(String[] args) throws InterruptedException {
AtomicIntegerArray array = new AtomicIntegerArray(new int[] { 0, 0 });
System.out.println(array);
System.out.println(array.getAndAdd(1, 2));
System.out.println(array);
}
}
1
2
3
[0, 0]
0
[0, 2]
3、原子更新引用类型

Atomic包提供了以下三个类:

  • AtomicReference:原子更新引用类型。
  • AtomicStampedReference:原子更新引用类型,内部使用Pair来存储元素值及其版本号。(可以解决CAS的ABA问题)
  • AtomicMarkableReferce:原子更新带有标记位的引用类型。
    • 有时候,并不关心引用变量更改了几次(ABA问题),只是单纯的关心是否更改过,所以就有了AtomicMarkableReference

这三个类提供的方法都差不多:

  1. 首先构造一个引用对象;
  2. 然后把引用对象set进Atomic类;
  3. 然后调用compareAndSet等一些方法去进行原子操作。

原理都是基于Unsafe实现,但AtomicReferenceFieldUpdater略有不同,更新的字段必须用volatile修饰

举个AtomicReference例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.concurrent.atomic.AtomicReference;

public class AtomicReferenceTest {

public static void main(String[] args){

// 创建两个Person对象,它们的id分别是101和102。
Person p1 = new Person(101);
Person p2 = new Person(102);
// 新建AtomicReference对象,初始化它的值为p1对象
AtomicReference ar = new AtomicReference(p1);
// 通过CAS设置ar。如果ar的值为p1的话,则将其设置为p2。
ar.compareAndSet(p1, p2);

Person p3 = (Person)ar.get();
System.out.println("p3 is "+p3);
System.out.println("p3.equals(p2)="+p3.equals(p2));
}
}

class Person {
volatile long id;
public Person(long id) {
this.id = id;
}
public String toString() {
return "id:"+id;
}
}
1
2
p3 is id:102
p3.equals(p2)=false

结果说明:

  • 新建AtomicReference对象ar时,将它初始化为p1。
  • 紧接着,通过CAS函数对它进行设置。如果ar的值为p1的话,则将其设置为p2。
  • 最后,获取ar对应的对象,并打印结果。p3.equals(p2)的结果为false。
    • 这是因为Person并没有覆盖equals()方法,而是采用继承自Object.java的equals()方法;而Object.java中的equals()实际上是调用”==”去比较两个对象,即比较两个对象的地址是否相等。

举个AtomicMarkableReference例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.atomic.AtomicMarkableReference;

import static cn.itcast.n2.util.Sleeper.sleep;

@Slf4j(topic = "c.Test38")
public class Test38 {
public static void main(String[] args) throws InterruptedException {
GarbageBag bag = new GarbageBag("装满了垃圾");
// 参数2 mark 可以看作一个标记,表示垃圾袋满了
AtomicMarkableReference<GarbageBag> ref = new AtomicMarkableReference<>(bag, true);

log.debug("start...");
GarbageBag prev = ref.getReference();
log.debug(prev.toString());

new Thread(() -> {
log.debug("start...");
bag.setDesc("空垃圾袋");
ref.compareAndSet(bag, bag, true, false);
log.debug(bag.toString());
},"保洁阿姨").start();

sleep(1);
log.debug("想换一只新垃圾袋?");
boolean success = ref.compareAndSet(prev, new GarbageBag("空垃圾袋"), true, false);
log.debug("换了么?" + success);
log.debug(ref.getReference().toString());
}
}

class GarbageBag {
String desc;

public GarbageBag(String desc) {
this.desc = desc;
}

public void setDesc(String desc) {
this.desc = desc;
}

@Override
public String toString() {
return super.toString() + " " + desc;
}
}
4、原子更新字段类(原子更新器)

Atomic包提供了四个类进行原子字段更新:

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器。
  • AtomicStampedFieldUpdater:原子更新带有版本号的引用类型。
  • AtomicReferenceFieldUpdater:上面已经说过此处不在赘述。

这四个类的使用方式都差不多,是基于反射的原子更新字段的值。要想原子地更新字段类需要两步:

  1. 第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性
  2. 第二步,更新类的字段必须使用public volatile修饰。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class TestAtomicIntegerFieldUpdater {

public static void main(String[] args){
TestAtomicIntegerFieldUpdater tIA = new TestAtomicIntegerFieldUpdater();
tIA.doIt();
}

public AtomicIntegerFieldUpdater<DataDemo> updater(String name){
return AtomicIntegerFieldUpdater.newUpdater(DataDemo.class,name);
}

public void doIt(){
DataDemo data = new DataDemo();
System.out.println("publicVar = "+updater("publicVar").getAndAdd(data, 2));
/*
* 由于在DataDemo类中属性value2/value3,在TestAtomicIntegerFieldUpdater中不能访问
*/
//System.out.println("protectedVar = "+updater("protectedVar").getAndAdd(data,2));
//System.out.println("privateVar = "+updater("privateVar").getAndAdd(data,2));

//报java.lang.IllegalArgumentException
//System.out.println("staticVar = "+updater("staticVar").getAndIncrement(data));

/*
* 下面报异常:must be integer
*/
//System.out.println("integerVar = "+updater("integerVar").getAndIncrement(data));
//System.out.println("longVar = "+updater("longVar").getAndIncrement(data));
}

}

class DataDemo{
public volatile int publicVar=3;
protected volatile int protectedVar=4;
private volatile int privateVar=5;

public volatile static int staticVar = 10;
//public final int finalVar = 11;

public volatile Integer integerVar = 19;
public volatile Long longVar = 18L;

}

再说下对于AtomicIntegerFieldUpdater 的使用稍微有一些限制和约束,约束如下:

  • 字段必须是volatile类型的,在线程之间共享变量时保证立即可见
    • eg:volatile int value = 3
  • 字段的描述类型(修饰符public/protected/default/private)是与调用者与操作对象字段的关系一致。也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作。但是对于父类的字段,子类是不能直接操作的,尽管子类可以访问父类的字段。
  • 只能是实例变量,不能是类变量,也就是说不能加static关键字
  • 只能是可修改变量,不能是final变量,因为final的语义就是不可修改实际上final的语义和volatile是有冲突的,这两个关键字不能同时存在。
  • 对于AtomicIntegerFieldUpdater和AtomicLongFieldUpdater只能修改int/long类型的字段,不能修改其包装类型(Integer/Long)。如果要修改包装类型就需要使用AtomicReferenceFieldUpdater
5、原子累加器

原子类型累加器JDK1.8引进的并发新技术,它可以看做AtomicLongAtomicDouble的部分加强类型。

原子类型累加器有如下四种:

  • DoubleAccumulator
  • DoubleAdder
  • LongAccumulator
  • LongAdder

由于上面四种累加器的原理类似,下面以LongAdder为列来介绍累加器的使用。

已经有AtomicLong的getAndIncrement()方法进行累加效果,为什么还要有LongAdder累加器?

  • 我们知道,AtomicLong是利用了底层的CAS操作来提供并发性的,比如addAndGet方法:

  • public final long addAndGet(long delta) {
        return unsafe.getAndAddLong(this, valueOffset, delta) + delta;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39

    - 上述方法调用了**Unsafe**类的**getAndAddLong**方法,该方法是个**native**方法,它的逻辑是采用自旋的方式不断更新目标值,直到更新成功。

    - 在并发量较低的环境下,线程冲突的概率比较小,自旋的次数不会很多。但是,高并发环境下,N个线程同时进行自旋操作,会出现大量失败并不断自旋的情况,此时**AtomicLong**的自旋会成为瓶颈。

    - 这就是**LongAdder**引入的初衷——解决高并发环境下**AtomicLong**的自旋瓶颈问题。

    - 而**LongAdder**的基本思路就是**分散热点**,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。

    - 如果要获取真正的long值,只要将各个槽中的变量值累加返回。

    - ConcurrentHashMap中的“分段锁”其实就是类似的思路。

    ###### 1、累加器性能比较——比较 AtomicLong 与 LongAdder

    ```java
    private static <T> void demo(Supplier<T> adderSupplier, Consumer<T> action) {
    T adder = adderSupplier.get();
    long start = System.nanoTime();
    List<Thread> ts = new ArrayList<>(); // 4 个线程,每人累加 50 万
    for (int i = 0; i < 40; i++) {
    ts.add(new Thread(() -> {
    for (int j = 0; j < 500000; j++) {
    action.accept(adder);
    }
    }));
    }
    ts.forEach(t -> t.start());
    ts.forEach(t -> {
    try {
    join();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    });

    long end = System.nanoTime();
    System.out.println(adder + " cost:" + (end - start)/1000_000);
    }

比较 AtomicLong 与 LongAdder:

1
2
3
4
5
6
7
for (int i = 0; i < 5; i++) {
demo(() -> new LongAdder(), adder -> adder.increment());
}

for (int i = 0; i < 5; i++) {
demo(() -> new AtomicLong(), adder -> adder.getAndIncrement());
}

输出:

1
2
3
4
5
6
7
8
9
10
11
1000000 cost:43 
1000000 cost:9
1000000 cost:7
1000000 cost:7
1000000 cost:7

1000000 cost:31
1000000 cost:27
1000000 cost:28
1000000 cost:24
1000000 cost:22

性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]… 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。

2、LongAdder源码

LongAdder 是并发大师 @author Doug Lea (大哥李)的作品,设计的非常精巧

类的继承关系:

1
2
3
public class LongAdder extends Striped64 implements Serializable {...}
// Striped64这个类实现一些核心操作,处理64位数据。
abstract class Striped64 extends Number {...}

LongAdder 类有几个关键域:(这几个的关键域定义在Striped64抽象类中)

1
2
3
4
5
6
// 累加单元数组, 懒惰初始化
transient volatile Cell[] cells;
// 基础值, 如果没有竞争, 则用 cas 累加这个域
transient volatile long base;
// 在 cells 创建或扩容时, 置为 1, 表示加锁(cas锁)
transient volatile int cellsBusy;

其中 Cell 即为累加单元

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 防止缓存行伪共享
@sun.misc.Contended
static final class Cell {
volatile long value;
Cell(long x) { value = x; }
// 最重要的方法, 用来 cas 方式进行累加, prev 表示旧值, next 表示新值
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}

// Unsafe mechanics
private static final sun.misc.Unsafe UNSAFE;
private static final long valueOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> ak = Cell.class;
valueOffset = UNSAFE.objectFieldOffset
(ak.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
}
3、缓存(伪共享问题)

缓存与内存的速度比较:

image-20210807034923018

因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。

而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)

缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中

CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。

可以通过缓存一致性协议(MESI)保证:

缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 “ 嗅探(snooping)" 协议

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效

伪共享:

image-20210807035727427

因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因此缓存行可以存下 2 个的 Cell 对象。这样问题来了:

  • Core-0 要修改 Cell[0]
  • Core-1 要修改 Cell[1]

无论谁修改成功,都会导致对方 Core 的缓存行失效,比如 Core-0 中 Cell[0]=6000, Cell[1]=8000 要累加Cell[0]=6001, Cell[1]=8000 Q,这时会让 Core-1 的缓存行失效。这种问题被叫做伪共享问题。

@sun.misc.Contended 用来解决这个伪共享问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效

image-20210807040114815

4、核心方法——increment()
1
2
3
public void increment() {
add(1L);
}

说明:increment()方法调用了add()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void add(long x) {
// as 为累加单元数组
// b 为基础值
// x 为累加值
Cell[] as; long b, v; int m; Cell a;
// 进入 if 的两个条件
// 1. as 有值, 表示已经发生过竞争, 进入 if
// 2. cas 给 base 累加时失败了, 表示 base 发生了竞争, 进入 if
if ((as = cells) != null || !casBase(b = base, b + x)) {
// uncontended 表示 cell 没有竞争
boolean uncontended = true;
if (
// as 还没有创建
as == null || (m = as.length - 1) < 0 ||
// 当前线程对应的 cell 还没有
(a = as[getProbe() & m]) == null ||
// cas 给当前线程的 cell 累加失败 uncontended=false ( a 为当前线程的 cell )
!(uncontended = a.cas(v = a.value, v + x)))
// 进入 cell 数组创建、cell 创建的流程
longAccumulate(x, null, uncontended);
}
}

add 流程图:

image-20210807040735271

说明:

  • cells是懒惰式创建的,当有竞争的才会创建cells数组,进而创建cells数组里面的cell对象
  • 当cells为空是说明当前竞争并不激烈,累加操作交给base去操作
    • 成功:返回
    • 失败:进入longAccumulate()方法
  • 当cells不为空说明当前存在竞争,查看当前线程cell是否创建
    • 没创建:进入longAccumulate()方法创建cell
    • 创建:累加操作交给创建的cell去操作
      • 成功:返回
      • 失败:进入longAccumulate()方法

由此刻看出,当累加失败或者没有创建cell时都会调用longAccumulate()方法,以下为longAccumulate()方法源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended) {
int h;
// 当前线程还没有对应的 cell, 需要随机生成一个 h 值用来将当前线程绑定到 cell
if ((h = getProbe()) == 0) {
// 初始化 probe
ThreadLocalRandom.current(); // force initialization
// h 对应新的 probe 值, 用来对应 cell
h = getProbe();
wasUncontended = true;
}
// collide 为 true 表示需要扩容
boolean collide = false; // True if last slot nonempty
for (;;) {
Cell[] as; Cell a; int n; long v;
// 已经有了 cells
if ((as = cells) != null && (n = as.length) > 0) {
// 还没有 cell
if ((a = as[(n - 1) & h]) == null) {
// 为 cellsBusy 加锁, 创建 cell, cell 的初始累加值为 x
// 成功则 break, 否则继续 continue 循环
if (cellsBusy == 0) { // Try to attach new Cell
// 创建cell对象
Cell r = new Cell(x); // Optimistically create
// 上锁
if (cellsBusy == 0 && casCellsBusy()) {
boolean created = false;
try { // Recheck under lock
Cell[] rs; int m, j;
if ((rs = cells) != null && // cells数组不为空
(m = rs.length) > 0 && // cells大小大于0
rs[j = (m - 1) & h] == null) { // cells是否有空槽位
// 如果槽位为空,则将创建的cell设置到空槽位当中
rs[j] = r;
created = true;
}
} finally {
// 解锁
cellsBusy = 0;
}
// 创建成功
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
// 有竞争, 改变线程对应的 cell 来重试 cas
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 null
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
// 如果 cells 长度已经超过了最大长度, 或者已经扩容, 改变线程对应的 cell 来重试 cas
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
// 确保 collide 为 false 进入此分支, 就不会进入下面的 else if 进行扩容了
else if (!collide)
collide = true;
// 加锁
else if (cellsBusy == 0 && casCellsBusy()) {
// 加锁成功, 扩容
try {
if (cells == as) { // Expand table unless stale
Cell[] rs = new Cell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
cells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 改变线程对应的 cell
h = advanceProbe(h);
}
/*
* 三个判断:
* 1、判断cellsBusy锁是否上锁
* 2、是否有其他线程创建了cells
* 3、尝试对cellsBusy上锁:把cellsBusy的值从0改到1
*/
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
// 加锁成功, 初始化 cells, 最开始长度为 2, 并填充一个 cell
// 成功则 break;
boolean init = false;
try { // Initialize table
// 再次判断是否其他线程已经了创建cells
if (cells == as) {
// 创建cells,初始大小是2,
// 但是同时创建了一个cell(只创建了一个cell,有一个cells的空间是空的)
// 线程不到万不得已才会使用到这个空的cell,体现了:线程对cell的懒惰初始化
Cell[] rs = new Cell[2];
rs[h & 1] = new Cell(x);
cells = rs;
init = true;
}
} finally {
// 解锁
cellsBusy = 0;
}
// 初始化成功
if (init)
break;
}
// 上两种情况失败, 尝试给 base 累加
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break; // Fall back on using base
}
}

longAccumulate流程图:

1
2
3
4
5
6
// 加锁成功,进入下面else if块的逻辑:创建cells
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {...}

// 加锁失败,进入下面else if块的逻辑:尝试给 base 累加
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))

image-20210807042131270

1
2
// cells不为空且cells的长度大于0:创建cell
if ((as = cells) != null && (n = as.length) > 0) {...}

image-20210807042151516

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 null
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
// 如果 cells 长度已经超过了最大长度, 或者已经扩容, 改变线程对应的 cell 来重试 cas
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
// 确保 collide 为 false 进入此分支, 就不会进入下面的 else if 进行扩容了
else if (!collide)
collide = true;
// 加锁
else if (cellsBusy == 0 && casCellsBusy()) {
// 加锁成功, 扩容
try {
if (cells == as) { // Expand table unless stale
// 先将长度进行翻倍 n<<1
Cell[] rs = new Cell[n << 1];
// 把老数组的对象复制到新数组当中
for (int i = 0; i < n; ++i)
rs[i] = as[i];
// 用新数组替换掉旧数组
cells = rs;
}
} finally {
// 解锁
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 改变线程对应的 cell
h = advanceProbe(h);

每个线程刚进入 longAccumulate 时,会尝试对应一个 cell 对象(找到一个坑位)

image-20210807042217185

获取最终结果通过 sum 方法:

1
2
3
4
5
6
7
8
9
10
11
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}

6、再说AutomicStampedReference解决CAS的ABA问题

1、AutomicStampedReference解决CAS的ABA问题

AtomicStampedReference主要维护包含一个对象引用以及一个可以自动更新的整数”stamp”的pair对象来解决ABA问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference; //维护对象引用
final int stamp; //用于标志版本
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
....

/**
* expectedReference :更新之前的原始值
* newReference : 将要更新的新值
* expectedStamp : 期待更新的标志版本
* newStamp : 将要更新的标志版本
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
// 获取当前的(元素值,版本号)对
Pair<V> current = pair;
return
// 引用没变
expectedReference == current.reference &&
// 版本号没变
expectedStamp == current.stamp &&
// 新引用等于旧引用
((newReference == current.reference &&
// 新版本号等于旧版本号
newStamp == current.stamp) ||
// 构造新的Pair对象并CAS更新
casPair(current, Pair.of(newReference, newStamp)));
}

private boolean casPair(Pair<V> cmp, Pair<V> val) {
// 调用Unsafe的compareAndSwapObject()方法CAS更新pair的引用为新引用
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
}
  • 如果元素值和版本号都没有变化,并且和新的也相同,返回true;
  • 如果元素值和版本号都没有变化,并且和新的不完全相同,就构造一个新的Pair对象并执行CAS更新pair。

可以看到,java中的实现跟我们上面讲的ABA的解决方法是一致的。

  • 首先,使用版本号控制;
  • 其次,不重复使用节点(Pair)的引用,每次都新建一个新的Pair来作为CAS比较的对象,而不是复用旧的;
  • 最后,外部传入元素值及版本号,而不是节点(Pair)的引用。
2、使用举例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private static AtomicStampedReference<Integer> atomicStampedRef =
new AtomicStampedReference<>(1, 0);
public static void main(String[] args){
Thread main = new Thread(() -> {
System.out.println("操作线程" + Thread.currentThread() +",初始值 a = " + atomicStampedRef.getReference());
int stamp = atomicStampedRef.getStamp(); //获取当前标识别
try {
Thread.sleep(1000); //等待1秒 ,以便让干扰线程执行
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean isCASSuccess = atomicStampedRef.compareAndSet(1,2,stamp,stamp +1); //此时expectedReference未发生改变,但是stamp已经被修改了,所以CAS失败
System.out.println("操作线程" + Thread.currentThread() +",CAS操作结果: " + isCASSuccess);
},"主操作线程");

Thread other = new Thread(() -> {
Thread.yield(); // 确保thread-main 优先执行
atomicStampedRef.compareAndSet(1,2,atomicStampedRef.getStamp(),atomicStampedRef.getStamp() +1);
System.out.println("操作线程" + Thread.currentThread() +",【increment】 ,值 = "+ atomicStampedRef.getReference());
atomicStampedRef.compareAndSet(2,1,atomicStampedRef.getStamp(),atomicStampedRef.getStamp() +1);
System.out.println("操作线程" + Thread.currentThread() +",【decrement】 ,值 = "+ atomicStampedRef.getReference());
},"干扰线程");

main.start();
other.start();
}
1
2
3
4
5
// 输出
> 操作线程Thread[主操作线程,5,main],初始值 a = 2
> 操作线程Thread[干扰线程,5,main],【increment】 ,值 = 2
> 操作线程Thread[干扰线程,5,main],【decrement】 ,值 = 1
> 操作线程Thread[主操作线程,5,main],CAS操作结果: false
3、java中还有哪些类可以解决ABA问题?

AtomicMarkableReference,它不是维护一个版本号,而是维护一个boolean类型的标记,标记值有修改,了解一下。

4、在日常的业务中怎么解决ABA问题?(用乐观锁的方法)
  • 加标志位做版本号,例如搞个自增的字段,操作一次就自增加一;
  • 加个时间戳,比较时间戳的值

3、JUC锁:LockSupport详解

LockSupport是锁中的基础,是一个提供锁机制的工具类。

1、BAT大厂的面试问题

  • 为什么LockSupport也是核心基础类?
    • AQS框架借助于两个类:Unsafe(提供CAS操作)LockSupport(提供park/unpark操作)
  • 写出分别通过wait/notify和LockSupport的park/unpark实现同步?
  • LockSupport.park()会释放锁资源吗?那么Condition.await()呢?
  • Thread.sleep()、Object.wait()、Condition.await()、LockSupport.park()的区别?重点
  • 如果在wait()之前执行了notify()会怎样?
  • 如果在park()之前执行了unpark()会怎样?

2、LockSupport简介

LockSupport用来创建锁和其他同步类的基本线程阻塞原语。简而言之,当调用LockSupport.park时,表示当前线程将会等待,直至获得许可,当调用LockSupport.unpark时,必须把等待获得许可的线程作为参数进行传递,好让此线程继续运行。

3、LockSupport源码分析

1、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class LockSupport {
// Hotspot implementation via intrinsics API
private static final sun.misc.Unsafe UNSAFE;
// 表示内存偏移地址
private static final long parkBlockerOffset;
// 表示内存偏移地址
private static final long SEED;
// 表示内存偏移地址
private static final long PROBE;
// 表示内存偏移地址
private static final long SECONDARY;

static {
try {
// 获取Unsafe实例
UNSAFE = sun.misc.Unsafe.getUnsafe();
// 线程类类型
Class<?> tk = Thread.class;
// 获取Thread的parkBlocker字段的内存偏移地址
parkBlockerOffset = UNSAFE.objectFieldOffset
(tk.getDeclaredField("parkBlocker"));
// 获取Thread的threadLocalRandomSeed字段的内存偏移地址
SEED = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomSeed"));
// 获取Thread的threadLocalRandomProbe字段的内存偏移地址
PROBE = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomProbe"));
// 获取Thread的threadLocalRandomSecondarySeed字段的内存偏移地址
SECONDARY = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomSecondarySeed"));
} catch (Exception ex) { throw new Error(ex); }
}
}

说明:UNSAFE字段表示sun.misc.Unsafe类,查看其源码,点击在这里,一般程序中不允许直接调用,而long型的表示实例对象相应字段在内存中的偏移地址,可以通过该偏移地址获取或者设置该字段的值。

2、类的构造函数
1
2
// 私有构造函数,无法被实例化
private LockSupport() {}

说明:LockSupport只有一个私有构造函数,无法被实例化。

3、核心函数分析

在分析LockSupport函数之前,先引入sun.misc.Unsafe类中的park和unpark函数,因为LockSupport的核心函数都是基于Unsafe类中定义的park和unpark函数,下面给出两个函数的定义:

1
2
public native void park(boolean isAbsolute, long time);
public native void unpark(Thread thread);

说明:对两个函数的说明如下:

  • park函数,阻塞线程,并且该线程在下列情况发生之前都会被阻塞:
    1. 调用unpark函数,释放该线程的许可。
    2. 该线程被中断。
    3. 设置的时间到了。并且,当time为绝对时间时,isAbsolute为true,否则,isAbsolute为false。当time为0时,表示无限等待,直到unpark发生。
  • unpark函数,释放线程的许可,即激活调用park后阻塞的线程。这个函数不是安全的,调用这个函数时要确保线程依旧存活。
1、park函数

park函数有两个重载版本,方法摘要如下:

1
2
public static void park()
public static void park(Object blocker)

说明:两个函数的区别在于park()函数没有没有blocker,即没有设置线程的parkBlocker字段。park(Object)型函数如下:

1
2
3
4
5
6
7
8
9
10
public static void park(Object blocker) {
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
// 获取许可
UNSAFE.park(false, 0L);
// 重新可运行后再此设置Blocker
setBlocker(t, null);
}

说明:调用park函数时,首先获取当前线程,然后设置当前线程的parkBlocker字段,即调用setBlocker函数,之后调用Unsafe类的park函数,之后再调用setBlocker函数。

那么问题来了,为什么要在此park函数中要调用两次setBlocker函数呢?

原因其实很简单,调用park函数时,当前线程首先设置好parkBlocker字段,然后再调用Unsafe的park函数,此后,当前线程就已经阻塞了,等待该线程的unpark函数被调用,所以后面的一个setBlocker函数无法运行,unpark函数被调用,该线程获得许可后,就可以继续运行了,也就运行第二个setBlocker,把该线程的parkBlocker字段设置为null,这样就完成了整个park函数的逻辑。

如果没有第二个setBlocker,那么之后没有调用park(Object blocker),而直接调用getBlocker函数,得到的还是前一个park(Object blocker)设置的blocker,显然是不符合逻辑的。总之,必须要保证在park(Object blocker)整个函数执行完后,该线程的parkBlocker字段又恢复为null。所以,park(Object)型函数里必须要调用setBlocker函数两次。

setBlocker方法如下:

1
2
3
4
private static void setBlocker(Thread t, Object arg) {
// 设置线程t的parkBlocker字段的值为arg
UNSAFE.putObject(t, parkBlockerOffset, arg);
}

说明:此方法用于设置线程t的parkBlocker字段的值为arg。

另外一个无参重载版本,park()函数如下:

1
2
3
4
public static void park() {
// 获取许可,设置时间为无限长,直到可以获取许可
UNSAFE.park(false, 0L);
}

说明:调用了park函数后,会禁用当前线程,除非许可可用。在以下三种情况之一发生之前,当前线程都将处于休眠状态,即下列情况发生时,当前线程会获取许可,可以继续运行:

  • 其他某个线程将当前线程作为目标调用 unpark。
  • 其他某个线程中断当前线程。
  • 该调用不合逻辑地(即毫无理由地)返回。
2、parkNanos函数

此函数表示在许可可用前禁用当前线程,并最多等待指定的等待时间。具体函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static void parkNanos(Object blocker, long nanos) {
if (nanos > 0) { // 时间大于0
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
// 获取许可,并设置了时间
UNSAFE.park(false, nanos);
// 设置许可
setBlocker(t, null);
}
}

说明:该函数也是调用了两次setBlocker函数,nanos参数表示相对时间,表示等待多长时间。

3、parkUntil函数

此函数表示在指定的时限前禁用当前线程,除非许可可用,具体函数如下:

1
2
3
4
5
6
7
8
9
public static void parkUntil(Object blocker, long deadline) {
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
UNSAFE.park(true, deadline);
// 设置Blocker为null
setBlocker(t, null);
}

说明:该函数也调用了两次setBlocker函数,deadline参数表示绝对时间,表示指定的时间。

4、unpark函数

此函数表示如果给定线程的许可尚不可用,则使其可用。如果线程在 park 上受阻塞,则它将解除其阻塞状态。否则,保证下一次调用 park 不会受阻塞。如果给定线程尚未启动,则无法保证此操作有任何效果。具体函数如下:

1
2
3
4
public static void unpark(Thread thread) {
if (thread != null) // 线程为不空
UNSAFE.unpark(thread); // 释放该线程许可
}

说明:释放许可,指定线程可以继续运行。

4、park/unpark 原理

每个线程都有自己的一个 Parker 对象(有C++编写),由三部分组成 _counter_cond_mutex 打个比喻

  • 线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
  • 调用 park 就是要看需不需要停下来歇息
    • 如果备用干粮耗尽,那么钻进帐篷歇息
    • 如果备用干粮充足,那么不需停留,继续前进
  • 调用 unpark,就好比令干粮充足
    • 如果这时线程还在帐篷,就唤醒让他继续前进
    • 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
      • 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
1、先调用park()

image-20210806000710338

  1. 当前线程调用 Unsafe.park() 方法
  2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
  3. 线程进入 _cond 条件变量阻塞
  4. 设置 _counter = 0
2、再调用unpark():

image-20210806000820108

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 唤醒 _cond 条件变量中的 Thread_0
  3. Thread_0 恢复运行
  4. 设置 _counter 为 0
3、先调用unpark(),再调用park():

image-20210806000922500

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 当前线程调用 Unsafe.park() 方法
  3. 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
  4. 设置 _counter 为 0

5、LockSupport示例说明

1、使用wait/notify实现线程同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyThread extends Thread {
@Override
public void run() {
synchronized (this) {
System.out.println("before notify");
notify();
System.out.println("after notify");
}
}
}

public class WaitAndNotifyDemo {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
synchronized (myThread) {
try {
myThread.start();
// 主线程睡眠3s
Thread.sleep(3000);
System.out.println("before wait");
// 阻塞主线程
myThread.wait();
System.out.println("after wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

运行结果:

1
2
3
4
before wait
before notify
after notify
after wait

说明:具体的流程图如下:

img

使用wait/notify实现同步时,必须先调用wait,后调用notify,如果先调用notify,再调用wait,将起不了作用。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyThread extends Thread {
@Override
public void run() {
synchronized (this) {
System.out.println("before notify");
notify();
System.out.println("after notify");
}
}
}

public class WaitAndNotifyDemo {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
myThread.start();
// 主线程睡眠3s
Thread.sleep(3000);
synchronized (myThread) {
try {
System.out.println("before wait");
// 阻塞主线程
myThread.wait();
System.out.println("after wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

运行结果:

1
2
3
before notify
after notify
before wait

说明:由于先调用了notify,再调用的wait,此时主线程还是会一直阻塞。

2、使用park/unpark实现线程同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before unpark");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 获取blocker
System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object));
// 释放许可
LockSupport.unpark((Thread) object);
// 休眠500ms,保证先执行park中的setBlocker(t, null);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 再次获取blocker
System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object));

System.out.println("after unpark");
}
}

public class test {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
5
6
before park
before unpark
Blocker info ParkAndUnparkDemo
after park
Blocker info null
after unpark

说明:本程序先执行park,然后在执行unpark,进行同步,并且在unpark的前后都调用了getBlocker,可以看到两次的结果不一样,并且第二次调用的结果为null,这是因为在调用unpark之后,执行了Lock.park(Object blocker)函数中的setBlocker(t, null)函数,所以第二次调用getBlocker时为null。

上例是先调用park,然后调用unpark,现在修改程序,先调用unpark,然后调用park,看能不能正确同步。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before unpark");
// 释放许可
LockSupport.unpark((Thread) object);
System.out.println("after unpark");
}
}

public class ParkAndUnparkDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
try {
// 主线程睡眠3s
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
before unpark
after unpark
before park
after park

说明:可以看到,在先调用unpark,再调用park时,仍能够正确实现同步,不会造成由wait/notify调用顺序不当所引起的阻塞。因此park/unpark相比wait/notify更加的灵活。

3、中断响应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before interrupt");
try {
// 休眠3s
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread thread = (Thread) object;
// 中断线程
thread.interrupt();
System.out.println("after interrupt");
}
}

public class InterruptDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
before park
before interrupt
after interrupt
after park

说明:可以看到,在主线程调用park阻塞后,在myThread线程中发出了中断信号,此时主线程会继续运行,也就是说明此时interrupt起到的作用与unpark一样。

6、更深入的理解

1、Thread.sleep()和Object.wait()的区别

首先,我们先来看看Thread.sleep()和Object.wait()的区别,这是一个烂大街的题目了,大家应该都能说上来两点:

  • Thread.sleep()不会释放占有的锁,Object.wait()会释放占有的锁;
  • Thread.sleep()必须传入时间,Object.wait()可传可不传,不传表示一直阻塞下去;
  • Thread.sleep()到时间了会自动唤醒,然后继续执行;
  • Object.wait()不带时间的,需要另一个线程使用Object.notify()唤醒;
  • Object.wait()带时间的,假如没有被notify,到时间了会自动唤醒,这时又分好两种情况,一是立即获取到了锁,线程自然会继续执行;二是没有立即获取锁,线程进入同步队列等待获取锁;

其实,他们俩最大的区别就是Thread.sleep()不会释放锁资源,Object.wait()会释放锁资源。

2、Thread.sleep()和Condition.await()的区别

Object.wait()和Condition.await()的原理是基本一致的,不同的是Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程的。

实际上,它在阻塞当前线程之前还干了两件事:

  1. 一是把当前线程添加到条件队列中
  2. 二是“完全”释放锁,也就是让state状态变量变为0,然后才是调用LockSupport.park()阻塞当前线程。
3、Thread.sleep()和LockSupport.park()的区别

LockSupport.park()还有几个兄弟方法——parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。

  • 从功能上来说,Thread.sleep()和LockSupport.park()方法类似,都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源;
  • Thread.sleep()没法从外部唤醒,只能自己醒过来;
  • LockSupport.park()方法可以被另一个线程调用LockSupport.unpark()方法唤醒;
  • Thread.sleep()方法声明上抛出了InterruptedException中断异常,所以调用者需要捕获这个异常或者再抛出;
  • LockSupport.park()方法不需要捕获中断异常;
  • Thread.sleep()本身就是一个native方法;
  • LockSupport.park()底层是调用的Unsafe的native方法;
4、Object.wait()和LockSupport.park()的区别

二者都会阻塞当前线程的运行,他们有什么区别呢? 经过上面的分析相信你一定很清楚了,真的吗? 往下看!

  • Object.wait()方法需要在synchronized块中执行;
  • LockSupport.park()可以在任意地方执行;
  • Object.wait()方法声明抛出了中断异常,调用者需要捕获或者再抛出;
  • LockSupport.park()不需要捕获中断异常;
  • Object.wait()不带超时的,需要另一个线程执行notify()来唤醒,但不一定继续执行后续内容;
  • LockSupport.park()不带超时的,需要另一个线程执行unpark()来唤醒,一定会继续执行后续内容;
  • 如果在wait()之前执行了notify()会怎样? 抛出IllegalMonitorStateException异常;
  • 如果在park()之前执行了unpark()会怎样? 线程不会被阻塞,直接跳过park(),继续执行后续内容;

park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。

5、LockSupport.park()会释放锁资源吗?

不会,它只负责阻塞当前线程,释放锁资源实际上是在Condition的await()方法中实现的。

4、AbstractQueuedSynchronizer(AQS)

AbstractQueuedSynchronizer抽象类是核心,需要重点掌握。它提供了一个基于FIFO队列,可以用于构建锁或者其他相关同步装置的基础框架。

1、BAT大厂的面试问题

  • 什么是AQS?为什么它是核心?
  • AQS的核心思想是什么?它是怎么实现的?底层数据结构等
  • AQS有哪些核心的方法?
  • AQS定义什么样的资源获取方式?
    • AQS定义了两种资源获取方式:
      • 独占(只有一个线程能访问执行,又根据是否按队列的顺序分为公平锁非公平锁,如ReentrantLock)
      • 共享(多个线程可同时访问执行,如SemaphoreCountDownLatchCyclicBarrier )。ReentrantReadWriteLock可以看成是组合式,允许多个线程同时对某一资源进行读。
  • AQS底层使用了什么样的设计模式?
    • 模板
  • AQS的应用示例?

2、AbstractQueuedSynchronizer简介

AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。

1、AQS核心思想

AQS核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。

AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

1
private volatile int state;//共享变量,使用volatile修饰保证线程可见性

状态信息通过protected类型的getState,setState,compareAndSetState进行操作:

1
2
3
4
5
6
7
8
9
10
11
12
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
2、AQS对资源的共享方式

AQS定义两种资源共享方式:

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。
    • 又可分为公平锁和非公平锁:
      • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
      • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的(抢不到就乖乖排队吧)
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。

ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。

3、AQS底层使用了模板方法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用)

使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放) 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。这和我们以往通过实现接口的方式有很大区别。

AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:

1
2
3
4
5
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。

以ReentrantLock为例:

  1. state初始化为0,表示未锁定状态。
  2. A线程lock()时,会调用tryAcquire()独占该锁并将state+1。
  3. 此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。
  4. 当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
4、总结

AbstractQueuedSynchronizer是阻塞式锁和相关的同步器工具的框架,特点:

  • 用 state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁
    • getState - 获取 state 状态
    • setState - 设置 state 状态
    • compareAndSetState - cas 机制设置 state 状态
    • 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
  • 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
  • 子类主要实现这样一些方法(默认抛出 UnsupportedOperationException
    • tryAcquire
    • tryRelease
    • tryAcquireShared
    • tryReleaseShared
    • isHeldExclusively

3、AbstractQueuedSynchronizer数据结构

AbstractQueuedSynchronizer类底层的数据结构是使用CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配

  • 其中Sync queue,即同步队列,是双向链表,包括head结点和tail结点,head结点主要用作后续的调度
  • Condition queue不是必须的,其是一个单向链表,只有当使用Condition时,才会存在此单向链表。并且可能会有多个Condition queue

image

4、AbstractQueuedSynchronizer源码分析

1、类的继承关系

AbstractQueuedSynchronizer继承自AbstractOwnableSynchronizer抽象类,并且实现了Serializable接口,可以进行序列化。

1
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable

其中AbstractOwnableSynchronizer抽象类的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {

// 版本序列号
private static final long serialVersionUID = 3737899427754241961L;
// 构造方法
protected AbstractOwnableSynchronizer() { }
// 独占模式下的线程
private transient Thread exclusiveOwnerThread;

// 设置独占线程
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}

// 获取独占线程
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
}

AbstractOwnableSynchronizer抽象类中,可以设置独占资源线程和获取独占资源线程。分别为setExclusiveOwnerThread与getExclusiveOwnerThread方法,这两个方法会被子类调用。

2、类的内部类

AbstractQueuedSynchronizer类有两个内部类,分别为Node类与ConditionObject类。

3、类的内部类——Node类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
static final class Node {
// 模式,分为共享与独占
// 共享模式
static final Node SHARED = new Node();
// 独占模式
static final Node EXCLUSIVE = null;
// 结点状态
// CANCELLED,值为1,表示当前的线程被取消
// SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,也就是unpark
// CONDITION,值为-2,表示当前节点在等待condition,也就是在condition队列中
// PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行
// 值为0,表示当前节点在sync队列中,等待着获取锁
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;

// 结点状态
volatile int waitStatus;
// 前驱结点
volatile Node prev;
// 后继结点
volatile Node next;
// 结点所对应的线程
volatile Thread thread;
// 下一个等待者
Node nextWaiter;

// 结点是否在共享模式下等待
final boolean isShared() {
return nextWaiter == SHARED;
}

// 获取前驱结点,若前驱结点为空,抛出异常
final Node predecessor() throws NullPointerException {
// 保存前驱结点
Node p = prev;
if (p == null) // 前驱结点为空,抛出异常
throw new NullPointerException();
else // 前驱结点不为空,返回
return p;
}

// 无参构造方法
Node() { // Used to establish initial head or SHARED marker
}

// 构造方法
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}

// 构造方法
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}

每个线程被阻塞的线程都会被封装成一个Node结点,放入队列。每个节点包含了一个Thread类型的引用,并且每个节点都存在一个状态,具体状态如下:

  • CANCELLED,值为1,表示当前的线程被取消。
  • SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,需要进行unpark操作。
  • CONDITION,值为-2,表示当前节点在等待condition,也就是在condition queue中。
  • PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行。
  • 值为0,表示当前节点在sync queue中,等待着获取锁。
4、类的内部类——ConditionObject类

这个类有点长,耐心看下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
// 内部类
public class ConditionObject implements Condition, java.io.Serializable {
// 版本号
private static final long serialVersionUID = 1173984872572414699L;
/** First node of condition queue. */
// condition队列的头结点
// 第一个等待节点
private transient Node firstWaiter;
/** Last node of condition queue. */
// condition队列的尾结点
// 最后一个等待节点
private transient Node lastWaiter;

/**
* Creates a new {@code ConditionObject} instance.
*/
// 构造方法
public ConditionObject() { }

// Internal methods

/**
* Adds a new waiter to wait queue.
* @return its new wait node
*/
// 添加新的Node(waiter)到wait队列
private Node addConditionWaiter() {
// 保存尾结点
Node t = lastWaiter;
// If lastWaiter is cancelled, clean out.
// 所有已取消的 Node 从队列链表删除
if (t != null && t.waitStatus != Node.CONDITION) { // 尾结点不为空,并且尾结点的状态不为CONDITION
// 清除状态为CONDITION的结点
unlinkCancelledWaiters();
// 将最后一个结点重新赋值给t
t = lastWaiter;
}
// 创建一个关联当前线程的新 Node, 添加至队列尾部
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null) // 尾结点为空
// 设置condition队列的头结点
firstWaiter = node;
else // 尾结点不为空
// 设置为节点的nextWaiter域为node结点
t.nextWaiter = node;
// 更新condition队列的尾结点
lastWaiter = node;
return node;
}

/**
* Removes and transfers nodes until hit non-cancelled one or
* null. Split out from signal in part to encourage compilers
* to inline the case of no waiters.
* @param first (non-null) the first node on condition queue
*/
// 唤醒 - 将没取消的第一个节点转移至 AQS 队列
private void doSignal(Node first) {
// 循环
do {
// 已经是尾节点了
if ( (firstWaiter = first.nextWaiter) == null) // 该节点的nextWaiter为空
// 设置尾结点为空
lastWaiter = null;
// 设置first结点的nextWaiter域
first.nextWaiter = null;
} while (// 将结点从condition队列转移到sync队列失败并且condition队列中的头结点不为空,一直循环
// 将等待队列中的 Node 转移至 AQS 队列, 不成功且还有节点则继续循环
!transferForSignal(first) &&
// 队列还有节点
(first = firstWaiter) != null);
}

/**
* Removes and transfers all nodes.
* @param first (non-null) the first node on condition queue
*/
private void doSignalAll(Node first) {
// condition队列的头结点尾结点都设置为空
lastWaiter = firstWaiter = null;
// 循环
do {
// 获取first结点的nextWaiter域结点
Node next = first.nextWaiter;
// 设置first结点的nextWaiter域为空
first.nextWaiter = null;
// 将first结点从condition队列转移到sync队列
transferForSignal(first);
// 重新设置first
first = next;
} while (first != null);
}

/**
* Unlinks cancelled waiter nodes from condition queue.
* Called only while holding lock. This is called when
* cancellation occurred during condition wait, and upon
* insertion of a new waiter when lastWaiter is seen to have
* been cancelled. This method is needed to avoid garbage
* retention in the absence of signals. So even though it may
* require a full traversal, it comes into play only when
* timeouts or cancellations occur in the absence of
* signals. It traverses all nodes rather than stopping at a
* particular target to unlink all pointers to garbage nodes
* without requiring many re-traversals during cancellation
* storms.
*/
// 从condition队列中清除状态为CANCEL的结点
private void unlinkCancelledWaiters() {
// 保存condition队列头结点
Node t = firstWaiter;
Node trail = null;
while (t != null) { // t不为空
// 下一个结点
Node next = t.nextWaiter;
if (t.waitStatus != Node.CONDITION) { // t结点的状态不为CONDTION状态
// 设置t节点的nextWaiter域为空
t.nextWaiter = null;
if (trail == null) // trail为空
// 重新设置condition队列的头结点
firstWaiter = next;
else // trail不为空
// 设置trail结点的nextWaiter域为next结点
trail.nextWaiter = next;
if (next == null) // next结点为空
// 设置condition队列的尾结点
lastWaiter = trail;
}
else // t结点的状态为CONDTION状态
// 设置trail结点
trail = t;
// 设置t结点
t = next;
}
}

// public methods

/**
* Moves the longest-waiting thread, if one exists, from the
* wait queue for this condition to the wait queue for the
* owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 唤醒一个等待线程。如果所有的线程都在等待此条件,则选择其中的一个唤醒。在从 await 返回之前,该线程必须重新获取锁。
// 唤醒 - 必须持有锁才能唤醒, 因此 doSignal 内无需考虑加锁
public final void signal() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒一个等待线程
doSignal(first);
}

/**
* Moves all threads from the wait queue for this condition to
* the wait queue for the owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。在从 await 返回之前,每个线程都必须重新获取锁。
// 全部唤醒 - 必须持有锁才能唤醒, 因此 doSignalAll 内无需考虑加锁
public final void signalAll() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒所有等待线程
doSignalAll(first);
}

/**
* Implements uninterruptible condition wait.
* <ol>
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* </ol>
*/
// 等待,当前线程在接到信号之前一直处于等待状态,不响应中断
// 不可打断等待 - 直到被唤醒
public final void awaitUninterruptibly() {
// 添加一个结点到等待队列
Node node = addConditionWaiter();
// 获取释放的状态,释放节点持有的锁
int savedState = fullyRelease(node);
boolean interrupted = false;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) { // 判断当前结点在不在同步队列之中
// 阻塞当前线程
LockSupport.park(this);
// 如果被打断, 仅设置打断状态
if (Thread.interrupted()) // 当前线程被中断
// 设置interrupted状态
interrupted = true;
}
// 唤醒后, 尝试竞争锁, 如果失败进入 AQS 队列
if (acquireQueued(node, savedState) || interrupted) //
selfInterrupt();
}

/*
* For interruptible waits, we need to track whether to throw
* InterruptedException, if interrupted while blocked on
* condition, versus reinterrupt current thread, if
* interrupted while blocked waiting to re-acquire.
*/

/** Mode meaning to reinterrupt on exit from wait */
// 打断模式 - 在退出等待时重新设置打断状态
private static final int REINTERRUPT = 1;
/** Mode meaning to throw InterruptedException on exit from wait */
// 打断模式 - 在退出等待时抛出异常
private static final int THROW_IE = -1;

/**
* Checks for interrupt, returning THROW_IE if interrupted
* before signalled, REINTERRUPT if after signalled, or
* 0 if not interrupted.
*/
// 判断打断模式
private int checkInterruptWhileWaiting(Node node) {
return Thread.interrupted() ?
(transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
0;
}

/**
* Throws InterruptedException, reinterrupts current thread, or
* does nothing, depending on mode.
*/
// 应用打断模式
private void reportInterruptAfterWait(int interruptMode)
throws InterruptedException {
if (interruptMode == THROW_IE)
throw new InterruptedException();
else if (interruptMode == REINTERRUPT)
selfInterrupt();
}

/**
* Implements interruptible condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled or interrupted.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* </ol>
*/
// 等待,当前线程在接到信号或被中断之前一直处于等待状态
// 等待 - 直到被唤醒或打断
public final void await() throws InterruptedException {
if (Thread.interrupted()) // 当前线程被中断,抛出异常
throw new InterruptedException();
// 在wait队列上添加一个结点
// 添加一个 Node 至等待队列
Node node = addConditionWaiter();
// 获取释放的状态
// 释放节点持有的锁
int savedState = fullyRelease(node);
int interruptMode = 0;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) {
// 阻塞当前线程
LockSupport.park(this);
// 如果被打断, 退出等待队列
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) // 检查结点等待时的中断类型
break;
}
// 退出等待队列后, 还需要获得 AQS 队列的锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
// 所有已取消的 Node 从队列链表删除
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
// 应用打断模式
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}

/**
* Implements timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
// 等待 - 直到被唤醒或打断或超时
public final long awaitNanos(long nanosTimeout)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
// 添加一个 Node 至等待队列
Node node = addConditionWaiter();
// 释放节点持有的锁
int savedState = fullyRelease(node);
// 获得最后期限
final long deadline = System.nanoTime() + nanosTimeout;
int interruptMode = 0;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) {
// 已超时, 退出等待队列
if (nanosTimeout <= 0L) {
transferAfterCancelledWait(node);
break;
}
// park 阻塞一定时间, spinForTimeoutThreshold 为 1000 ns
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
// 如果被打断, 退出等待队列
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
nanosTimeout = deadline - System.nanoTime();
}
// 退出等待队列后, 还需要获得 AQS 队列的锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
// 所有已取消的 Node 从队列链表删除
if (node.nextWaiter != null)
unlinkCancelledWaiters();
// 应用打断模式
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return deadline - System.nanoTime();
}

/**
* Implements absolute timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* <li> If timed out while blocked in step 4, return false, else true.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态
// 等待 - 直到被唤醒或打断或超时, 逻辑类似于 awaitNanos
public final boolean awaitUntil(Date deadline)
throws InterruptedException {
long abstime = deadline.getTime();
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
boolean timedout = false;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (System.currentTimeMillis() > abstime) {
timedout = transferAfterCancelledWait(node);
break;
}
LockSupport.parkUntil(this, abstime);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return !timedout;
}

/**
* Implements timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* <li> If timed out while blocked in step 4, return false, else true.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。此方法在行为上等效于: awaitNanos(unit.toNanos(time)) > 0
// 等待 - 直到被唤醒或打断或超时, 逻辑类似于 awaitNanos
public final boolean await(long time, TimeUnit unit)
throws InterruptedException {
long nanosTimeout = unit.toNanos(time);
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
final long deadline = System.nanoTime() + nanosTimeout;
boolean timedout = false;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (nanosTimeout <= 0L) {
timedout = transferAfterCancelledWait(node);
break;
}
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
nanosTimeout = deadline - System.nanoTime();
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return !timedout;
}

// support for instrumentation

/**
* Returns true if this condition was created by the given
* synchronization object.
*
* @return {@code true} if owned
*/
final boolean isOwnedBy(AbstractQueuedSynchronizer sync) {
return sync == AbstractQueuedSynchronizer.this;
}

/**
* Queries whether any threads are waiting on this condition.
* Implements {@link AbstractQueuedSynchronizer#hasWaiters(ConditionObject)}.
*
* @return {@code true} if there are any waiting threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 查询是否有正在等待此条件的任何线程
protected final boolean hasWaiters() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION)
return true;
}
return false;
}

/**
* Returns an estimate of the number of threads waiting on
* this condition.
* Implements {@link AbstractQueuedSynchronizer#getWaitQueueLength(ConditionObject)}.
*
* @return the estimated number of waiting threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 返回正在等待此条件的线程数估计值
protected final int getWaitQueueLength() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int n = 0;
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION)
++n;
}
return n;
}

/**
* Returns a collection containing those threads that may be
* waiting on this Condition.
* Implements {@link AbstractQueuedSynchronizer#getWaitingThreads(ConditionObject)}.
*
* @return the collection of threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 返回包含那些可能正在等待此条件的线程集合
protected final Collection<Thread> getWaitingThreads() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
ArrayList<Thread> list = new ArrayList<Thread>();
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION) {
Thread t = w.thread;
if (t != null)
list.add(t);
}
}
return list;
}
}

此类实现了Condition接口,Condition接口定义了条件操作规范,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface Condition {

// 等待,当前线程在接到信号或被中断之前一直处于等待状态
void await() throws InterruptedException;

// 等待,当前线程在接到信号之前一直处于等待状态,不响应中断
void awaitUninterruptibly();

//等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
long awaitNanos(long nanosTimeout) throws InterruptedException;

// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。此方法在行为上等效于: awaitNanos(unit.toNanos(time)) > 0
boolean await(long time, TimeUnit unit) throws InterruptedException;

// 等待,当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态
boolean awaitUntil(Date deadline) throws InterruptedException;

// 唤醒一个等待线程。如果所有的线程都在等待此条件,则选择其中的一个唤醒。在从 await 返回之前,该线程必须重新获取锁。
void signal();

// 唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。在从 await 返回之前,每个线程都必须重新获取锁。
void signalAll();
}

Condition接口中定义了await、signal方法,用来等待条件、释放条件。之后会详细分析CondtionObject的源码。

5、类的属性

属性中包含了头结点head尾结点tail状态state自旋时间spinForTimeoutThreshold,还有AbstractQueuedSynchronizer抽象的属性在内存中的偏移地址,通过该偏移地址,可以获取和设置该属性的值,同时还包括一个静态初始化块,用于加载内存偏移地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
implements java.io.Serializable {
// 版本号
private static final long serialVersionUID = 7373984972572414691L;
// 头结点
private transient volatile Node head;
// 尾结点
private transient volatile Node tail;
// 状态
private volatile int state;
// 自旋时间
static final long spinForTimeoutThreshold = 1000L;

// Unsafe类实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
// state内存偏移地址
private static final long stateOffset;
// head内存偏移地址
private static final long headOffset;
// state内存偏移地址
private static final long tailOffset;
// tail内存偏移地址
private static final long waitStatusOffset;
// next内存偏移地址
private static final long nextOffset;
// 静态初始化块
static {
try {
stateOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
headOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("head"));
tailOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
waitStatusOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("waitStatus"));
nextOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("next"));
} catch (Exception ex) { throw new Error(ex); }
}
}
6、类的构造方法

此类构造方法为从抽象构造方法,供子类调用:

1
protected AbstractQueuedSynchronizer() {}
7、类的核心方法——acquire方法

该方法以独占模式获取(资源),忽略中断,即线程在aquire过程中,中断此线程是无效的。源码如下:

1
2
3
4
5
6
7
8
9
10
public final void acquire(int arg) {
// 当 tryAcquire 返回为 false 时, 先调用AQS的addWaiter , 接着 acquireQueued
if (
// 尝试获得写锁失败
!tryAcquire(arg) &&
// 将当前线程关联到一个 Node 对象上, 模式为独占模式
// 进入 AQS 队列阻塞
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

由上述源码可以知道,当一个线程调用acquire时,调用方法流程如下:

java-thread-x-juc-aqs-2

  • 首先调用tryAcquire方法,调用此方法的线程会试图在独占模式下获取对象状态。此方法应该查询是否允许它在独占模式下获取对象状态,如果允许,则获取它。在AbstractQueuedSynchronizer源码中默认会抛出一个异常,即需要子类去重写此方法完成自己的逻辑。之后会进行分析。
  • 若tryAcquire失败,则调用addWaiter方法,addWaiter方法完成的功能是将调用此方法的线程封装成为一个结点并放入Sync queue
  • 调用acquireQueued方法,此方法完成的功能是Sync queue中的结点不断尝试获取资源,若成功,则返回true,否则,返回false
  • 由于tryAcquire默认实现是抛出异常,所以此时,不进行分析,之后会结合一个例子进行分析。

首先分析addWaiter方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 添加等待者
private Node addWaiter(Node mode) {
// 将当前线程关联到一个 Node 对象上, 模式为独占模式
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
// 如果 tail 不为 null, cas 尝试将 Node 对象加入 AQS 队列尾部
Node pred = tail;
if (pred != null) { // 尾结点不为空,即已经被初始化
// 将node结点的prev域连接到尾结点
node.prev = pred;
if (compareAndSetTail(pred, node)) { // 比较pred是否为尾结点,是则将尾结点设置为node
// 双向链表
// 设置尾结点的next域为node
pred.next = node;
return node; // 返回新生成的结点
}
}
// 尝试将 Node 加入 AQS
enq(node); // 尾结点为空(即还没有被初始化过),或者是compareAndSetTail操作失败,则入队列
return node;
}

addWaiter方法使用快速添加的方式往sync queue尾部添加结点,如果sync queue队列还没有初始化,则会使用enq插入队列中,enq方法源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private Node enq(final Node node) {
for (;;) { // 无限循环,确保结点能够成功入队列
// 保存尾结点
Node t = tail;
if (t == null) { // 尾结点为空,即还没被初始化
// 还没有, 设置 head 为哨兵节点(不对应线程,状态为 0)
if (compareAndSetHead(new Node())) // 头结点为空,并设置头结点为新生成的结点
tail = head; // 头结点与尾结点都指向同一个新生结点
} else { // 尾结点不为空,即已经被初始化过
// 将node结点的prev域连接到尾结点
// cas 尝试将 Node 对象加入 AQS 队列尾部
node.prev = t;
if (compareAndSetTail(t, node)) { // 比较结点t是否为尾结点,若是则将尾结点设置为node
// 设置尾结点的next域为node
t.next = node;
return t; // 返回尾结点
}
}
}
}

enq方法会使用无限循环来确保节点的成功插入

现在,分析acquireQueue方法。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// sync队列中的结点在独占且忽略中断的模式下获取(资源)
final boolean acquireQueued(final Node node, int arg) {
// 标志
boolean failed = true;
try {
// 中断标志
boolean interrupted = false;
for (;;) { // 无限循环
// 获取node节点的前驱结点
final Node p = node.predecessor();
// 上一个节点是 head, 表示轮到自己(当前线程对应的 node)了, 尝试获取
if (p == head && tryAcquire(arg)) { // 前驱为头结点并且成功获得锁
// 获取成功, 设置自己(当前线程对应的 node)为 head
setHead(node); // 设置头结点
// 上一个节点 help GC
p.next = null; // help GC
failed = false; // 设置标志
// 返回中断标记 false
return interrupted;
}
if (// 判断是否应当 park,
shouldParkAfterFailedAcquire(p, node) &&
// park 等待, 此时 Node 的状态被置为 Node.SIGNAL
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

首先获取当前节点的前驱节点,如果前驱节点是头结点并且能够获取(资源),代表该当前节点能够占有锁,设置头结点为当前节点,返回。否则,调用shouldParkAfterFailedAcquire和parkAndCheckInterrupt方法,首先,我们看shouldParkAfterFailedAcquire方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 当获取(资源)失败后,检查并且更新结点状态
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱结点的状态
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) // 状态为SIGNAL,为-1
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
// 前驱节点都在阻塞, 那么自己也阻塞好了
// 可以进行park操作
return true;
// > 0 表示取消状态
if (ws > 0) { // 表示状态为CANCELLED,为1
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
// 上一个节点取消, 那么重构删除前面所有取消的节点, 返回到外层循环重试
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0); // 找到pred结点前面最近的一个状态不为CANCELLED的结点
// 赋值pred结点的next域
pred.next = node;
} else { // 为PROPAGATE -3 或者是0 表示无状态,(为CONDITION -2时,表示此节点在condition queue中)
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
// 这次还没有阻塞
// 但下次如果重试不成功, 则需要阻塞,这时需要设置上一个节点状态为 Node.SIGNAL
// 比较并设置前驱结点的状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
// 不能进行park操作
return false;
}

只有当该节点的前驱结点的状态为SIGNAL时,才可以对该结点所封装的线程进行park操作。否则,将不能进行park操作。再看parkAndCheckInterrupt方法,源码如下:

1
2
3
4
5
6
// 进行park操作并且返回该线程是否被中断
private final boolean parkAndCheckInterrupt() {
// 在许可可用之前禁用当前线程,并且设置了blocker
LockSupport.park(this);
return Thread.interrupted(); // 当前线程是否已被中断,并清除中断标记位
}

parkAndCheckInterrupt方法里的逻辑是首先执行park操作,即禁用当前线程,然后返回该线程是否已经被中断。再看final块中的cancelAcquire方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 取消继续获取(资源)
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
// node为空,返回
if (node == null)
return;
// 设置node结点的thread为空
node.thread = null;

// Skip cancelled predecessors
// 保存node的前驱结点
Node pred = node.prev;
while (pred.waitStatus > 0) // 找到node前驱结点中第一个状态小于0的结点,即不为CANCELLED状态的结点
node.prev = pred = pred.prev;

// predNext is the apparent node to unsplice. CASes below will
// fail if not, in which case, we lost race vs another cancel
// or signal, so no further action is necessary.
// 获取pred结点的下一个结点
Node predNext = pred.next;

// Can use unconditional write instead of CAS here.
// After this atomic step, other Nodes can skip past us.
// Before, we are free of interference from other threads.
// 设置node结点的状态为CANCELLED
node.waitStatus = Node.CANCELLED;

// If we are the tail, remove ourselves.
if (node == tail && compareAndSetTail(node, pred)) { // node结点为尾结点,则设置尾结点为pred结点
// 比较并设置pred结点的next节点为null
compareAndSetNext(pred, predNext, null);
} else { // node结点不为尾结点,或者比较设置不成功
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) { // (pred结点不为头结点,并且pred结点的状态为SIGNAL)或者
// pred结点状态小于等于0,并且比较并设置等待状态为SIGNAL成功,并且pred结点所封装的线程不为空
// 保存结点的后继
Node next = node.next;
if (next != null && next.waitStatus <= 0) // 后继不为空并且后继的状态小于等于0
compareAndSetNext(pred, predNext, next); // 比较并设置pred.next = next;
} else {
unparkSuccessor(node); // 释放node的前一个结点
}

node.next = node; // help GC
}
}

该方法完成的功能就是取消当前线程对资源的获取,即设置该结点的状态为CANCELLED,接着我们再看unparkSuccessor方法,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 释放后继结点
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
// 获取node结点的等待状态
// 如果状态为 Node.SIGNAL 尝试重置状态为 0
// 不成功也可以
int ws = node.waitStatus;
if (ws < 0) // 状态值小于0,为SIGNAL -1 或 CONDITION -2 或 PROPAGATE -3
// 比较并且设置结点等待状态,设置为0
compareAndSetWaitStatus(node, ws, 0);

/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
// 获取node节点的下一个结点
// 找到需要 unpark 的节点, 但本节点从 AQS 队列中脱离, 是由唤醒节点完成的
Node s = node.next;
// 不考虑已取消的节点, 从 AQS 队列从后至前找到队列最前面需要 unpark 的节点
if (s == null || s.waitStatus > 0) { // 下一个结点为空或者下一个节点的等待状态大于0,即为CANCELLED
// s赋值为空
s = null;
// 从尾结点开始从后往前开始遍历
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0) // 找到等待状态小于等于0的结点,找到最前的状态小于等于0的结点
// 保存结点
s = t;
}
if (s != null) // 该结点不为为空,释放许可
LockSupport.unpark(s.thread);
}

该方法的作用就是为了释放node节点的后继结点

对于cancelAcquire与unparkSuccessor方法,如下示意图可以清晰的表示:

image

其中node为参数,在执行完cancelAcquire方法后的效果就是unpark了s结点所包含的t4线程

现在,再来看acquireQueued方法的整个的逻辑。逻辑如下:

  1. 判断结点的前驱是否为head并且是否成功获取(资源)。
  2. 若步骤1均满足,则设置结点为head,之后会判断是否finally模块,然后返回。
  3. 若步骤2不满足,则判断是否需要park当前线程,是否需要park当前线程的逻辑是判断结点的前驱结点的状态是否为SIGNAL,若是,则park当前结点,否则,不进行park操作。
  4. 若park了当前线程,之后某个线程对本线程unpark后,并且本线程也获得机会运行。那么,将会继续进行步骤①的判断。

注意:

  • 是否需要 unpark 是由当前节点的前驱节点的 waitStatus == Node.SIGNAL 来决定,而不是本节点的 waitStatus 决定
8、类的核心方法——release方法

以独占模式释放对象,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final boolean release(int arg) {
// 尝试释放锁
if (tryRelease(arg)) { // 释放成功
// 保存头结点
// 队列头节点 unpark
Node h = head;
// 队列不为 null
// waitStatus == Node.SIGNAL 才需要 unpark
if (h != null && h.waitStatus != 0) // 头结点不为空并且头结点状态不为0
// unpark AQS 中等待的线程
unparkSuccessor(h); //释放头结点的后继结点
return true;
}
return false;
}

其中,tryRelease的默认实现是抛出异常,需要具体的子类实现,如果tryRelease成功,那么如果头结点不为空并且头结点的状态不为0,则释放头结点的后继结点,unparkSuccessor方法已经分析过,不再累赘。

除了release()方法之外,还有一个方法——fullyRelease()用来释放锁:因为某线程可能重入,需要将 state 全部释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 因为某线程可能重入,需要将 state 全部释放
final int fullyRelease(Node node) {
boolean failed = true;
try {
int savedState = getState(); if (release(savedState)) {
failed = false;
return savedState;
} else {
throw new IllegalMonitorStateException();
}
} finally {
if (failed)
node.waitStatus = Node.CANCELLED;
}
}

对于其他方法我们也可以分析,与前面分析的方法大同小异,所以,不再累赘。

5、AbstractQueuedSynchronizer示例详解一

借助下面示例来分析AbstractQueuedSyncrhonizer内部的工作机制。示例源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class MyThread extends Thread {
private Lock lock;
public MyThread(String name, Lock lock) {
super(name);
this.lock = lock;
}

public void run () {
lock.lock();
try {
System.out.println(Thread.currentThread() + " running");
} finally {
lock.unlock();
}
}
}
public class AbstractQueuedSynchonizerDemo {
public static void main(String[] args) {
Lock lock = new ReentrantLock();

MyThread t1 = new MyThread("t1", lock);
MyThread t2 = new MyThread("t2", lock);
t1.start();
t2.start();
}
}

运行结果(可能的一种):

1
2
Thread[t1,5,main] running
Thread[t2,5,main] running

结果分析:从示例可知,线程t1与t2共用了一把锁,即同一个lock。可能会存在如下一种时序:

image

说明:首先线程t1先执行lock.lock操作,然后t2执行lock.lock操作,然后t1执行lock.unlock操作,最后t2执行lock.unlock操作。基于这样的时序,分析AbstractQueuedSynchronizer内部的工作机制:

  • t1线程调用lock.lock方法,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:其中,前面的部分表示哪个类,后面是具体的类中的哪个方法,AQS表示AbstractQueuedSynchronizer类,AOS表示AbstractOwnableSynchronizer类。
  • t2线程调用lock.lock方法,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:经过一系列的方法调用,最后达到的状态是禁用t2线程,因为调用了LockSupport.lock。
  • t1线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:t1线程中调用lock.unlock后,经过一系列的调用,最终的状态是释放了许可,因为调用了LockSupport.unpark。这时,t2线程就可以继续运行了。此时,会继续恢复t2线程运行环境,继续执行LockSupport.park后面的语句,即进一步调用如下:
    • image
    • 说明:在上一步调用了LockSupport.unpark后,t2线程恢复运行,则运行parkAndCheckInterrupt,之后,继续运行acquireQueued方法,最后达到的状态是头结点head与尾结点tail均指向了t2线程所在的结点,并且之前的头结点已经从sync队列中断开了。
  • t2线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:t2线程执行lock.unlock后,最终达到的状态还是与之前的状态一样。

6、AbstractQueuedSynchronizer示例详解二

下面我们结合Condition实现生产者与消费者,来进一步分析AbstractQueuedSynchronizer的内部工作机制。

Depot(仓库)类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Depot {
private int size;
private int capacity;
private Lock lock;
private Condition fullCondition;
private Condition emptyCondition;

public Depot(int capacity) {
this.capacity = capacity;
lock = new ReentrantLock();
fullCondition = lock.newCondition();
emptyCondition = lock.newCondition();
}

public void produce(int no) {
lock.lock();
int left = no;
try {
while (left > 0) {
while (size >= capacity) {
System.out.println(Thread.currentThread() + " before await");
fullCondition.await();
System.out.println(Thread.currentThread() + " after await");
}
int inc = (left + size) > capacity ? (capacity - size) : left;
left -= inc;
size += inc;
System.out.println("produce = " + inc + ", size = " + size);
emptyCondition.signal();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}

public void consume(int no) {
lock.lock();
int left = no;
try {
while (left > 0) {
while (size <= 0) {
System.out.println(Thread.currentThread() + " before await");
emptyCondition.await();
System.out.println(Thread.currentThread() + " after await");
}
int dec = (size - left) > 0 ? left : size;
left -= dec;
size -= dec;
System.out.println("consume = " + dec + ", size = " + size);
fullCondition.signal();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Consumer {
private Depot depot;
public Consumer(Depot depot) {
this.depot = depot;
}

public void consume(int no) {
new Thread(new Runnable() {
@Override
public void run() {
depot.consume(no);
}
}, no + " consume thread").start();
}
}

class Producer {
private Depot depot;
public Producer(Depot depot) {
this.depot = depot;
}

public void produce(int no) {
new Thread(new Runnable() {

@Override
public void run() {
depot.produce(no);
}
}, no + " produce thread").start();
}
}

public class ReentrantLockDemo {
public static void main(String[] args) throws InterruptedException {
Depot depot = new Depot(500);
new Producer(depot).produce(500);
new Producer(depot).produce(200);
new Consumer(depot).consume(500);
new Consumer(depot).consume(200);
}
}

运行结果(可能的一种):

1
2
3
4
5
6
7
8
produce = 500, size = 500
Thread[200 produce thread,5,main] before await
consume = 500, size = 0
Thread[200 consume thread,5,main] before await
Thread[200 produce thread,5,main] after await
produce = 200, size = 200
Thread[200 consume thread,5,main] after await
consume = 200, size = 0

说明:根据结果,我们猜测一种可能的时序如下:

image

说明:p1代表produce 500的那个线程,p2代表produce 200的那个线程,c1代表consume 500的那个线程,c2代表consume 200的那个线程。

  1. p1线程调用lock.lock,获得锁,继续运行,方法调用顺序在前面已经给出。
  2. p2线程调用lock.lock,由前面的分析可得到如下的最终状态:
    • java-thread-x-juc-aqs-11
    • 说明:p2线程调用lock.lock后,会禁止p2线程的继续运行,因为执行了LockSupport.park操作。
  3. c1线程调用lock.lock,由前面的分析得到如下的最终状态:
    • image
    • 说明:最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含p2的结点)的waitStatus变为了SIGNAL。
  4. c2线程调用lock.lock,由前面的分析得到如下的最终状态:
    • image
    • 说明:最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含c1的结点)的waitStatus变为了SIGNAL。
  5. p1线程执行emptyCondition.signal,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:AQS.CO表示AbstractQueuedSynchronizer.ConditionObject类。此时调用signal方法不会产生任何其他效果。
  6. p1线程执行lock.unlock,根据前面的分析可知,最终的状态如下:
    • image
    • 说明:此时,p2线程所在的结点为头结点,并且其他两个线程(c1、c2)依旧被禁止,所以,此时p2线程继续运行,执行用户逻辑。
  7. p2线程执行fullCondition.await,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:最终到达的状态是新生成了一个结点,包含了p2线程,此结点在condition queue中;并且sync queue中p2线程被禁止了,因为在执行了LockSupport.park操作。从方法一些调用可知,在await操作中线程会释放锁资源,供其他线程获取。同时,head结点后继结点的包含的线程的许可被释放了,故其可以继续运行。由于此时,只有c1线程可以运行,故运行c1。
  8. 继续运行c1线程,c1线程由于之前被park了,所以此时恢复,继续之前的步骤,即还是执行前面提到的acquireQueued方法,之后,c1判断自己的前驱结点为head,并且可以获取锁资源,最终到达的状态如下:
    • image
    • 说明:其中,head设置为包含c1线程的结点,c1继续运行。
  9. c1线程执行fullCondtion.signal,其方法调用顺序如下,只给出了主要的方法调用:
    • java-thread-x-juc-aqs-17
    • 说明:signal方法达到的最终结果是将包含p2线程的结点从condition queue中转移到sync queue中,之后condition queue为null,之前的尾结点的状态变为SIGNAL。
  10. c1线程执行lock.unlock操作,根据之前的分析,经历的状态变化如下:
    • image
    • 说明:最终c2线程会获取锁资源,继续运行用户逻辑。
  11. c2线程执行emptyCondition.await,由前面的第七步分析,可知最终的状态如下:
    • image
    • 说明:await操作将会生成一个结点放入condition queue中与之前的一个condition queue是不相同的,并且unpark头结点后面的结点,即包含线程p2的结点。
  12. p2线程被unpark,故可以继续运行,经过CPU调度后,p2继续运行,之后p2线程在AQS:await方法中被park,继续AQS.CO:await方法的运行,其方法调用顺序如下,只给出了主要的方法调用:
    • java-thread-x-juc-aqs-20
  13. p2继续运行,执行emptyCondition.signal,根据第九步分析可知,最终到达的状态如下:
    • java-thread-x-juc-aqs-21
    • 说明:最终,将condition queue中的结点转移到sync queue中,并添加至尾部,condition queue会为空,并且将head的状态设置为SIGNAL。
  14. p2线程执行lock.unlock操作,根据前面的分析可知,最后的到达的状态如下:
    • image
    • 说明: unlock操作会释放c2线程的许可,并且将头结点设置为c2线程所在的结点。
      • c2线程继续运行,执行fullCondition. signal,由于此时fullCondition的condition queue已经不存在任何结点了,故其不会产生作用。
      • c2执行lock.unlock,由于c2是sync队列中最后一个结点,故其不会再调用unparkSuccessor了,直接返回true。即整个流程就完成了。

7、AbstractQueuedSynchronizer总结

对于AbstractQueuedSynchronizer的分析,最核心的就是sync queue的分析。

  • 每一个结点都是由前一个结点唤醒
  • 当结点发现前驱结点是head并且尝试获取成功,则会轮到该线程运行
  • condition queue中的结点向sync queue中转移是通过signal操作完成的
  • 当结点的状态为SIGNAL时,表示后面的结点需要运行

8、使用AQS自定义同步器——实现不可重入锁

自定义锁(不可重入锁):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 自定义锁(不可重入锁)
class MyLock implements Lock {

// 独占锁 同步器类
class MySync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
if(compareAndSetState(0, 1)) {
// 加上了锁,并设置 owner 为当前线程
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}

@Override
protected boolean tryRelease(int arg) {
// 注意这里:把setState放在后面是因为state是有volatile修饰的,会在setState处放入写屏障
// 用来保证写屏障前面的写操作对其他线程可见
// 而setExclusiveOwnerThread(null);设置的exclusiveOwnerThread没有用volatile修饰
// 所以如果没把setState放在后面,exclusiveOwnerThrea不能保证可见性
setExclusiveOwnerThread(null);
setState(0);
return true;
}

@Override // 是否持有独占锁
protected boolean isHeldExclusively() {
return getState() == 1;
}

public Condition newCondition() {
return new ConditionObject();
}
}

private MySync sync = new MySync();

@Override // 加锁(不成功会进入等待队列)
public void lock() {
sync.acquire(1);
}

@Override // 加锁,可打断
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}

@Override // 尝试加锁(一次)
public boolean tryLock() {
return sync.tryAcquire(1);
}

@Override // 尝试加锁,带超时
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(time));
}

@Override // 解锁
public void unlock() {
sync.release(1);
}

@Override // 创建条件变量
public Condition newCondition() {
return sync.newCondition();
}
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class TestAqs {
public static void main(String[] args) {
MyLock lock = new MyLock();
new Thread(() -> {
lock.lock();
// lock.lock();
try {
log.debug("locking...");
sleep(1);
} finally {
log.debug("unlocking...");
lock.unlock();
}
},"t1").start();

new Thread(() -> {
lock.lock();
try {
log.debug("locking...");
} finally {
log.debug("unlocking...");
lock.unlock();
}
},"t2").start();
}
}

5、Lock接口

类结构总览:

image

1、什么是Lock

Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象。

Lock 锁实现提供了比使用同步方法和语句可以获得的更广泛的锁操作。它们允许更灵活的结构,可能具有非常不同的属性,并且可能支持多个关联的条件对象。Lock 提供了比 synchronized 更多的功能。

2、Lock接口(源码)

1
2
3
4
5
6
7
8
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}

下面来逐个讲述 Lock 接口中每个方法的使用。

1、lock()方法 与 unlock()方法
  • lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。
    • lock锁是不会被打断interrupt的,要想被interrupt打断的话,就得使用lock.lockInterruptibly()进行上锁
  • unlock()方法也是平常使用得最多的一个方法,就是用来释放锁。一般与lock()方法搭配使用。

采用 Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用 Lock 必须在 try{}catch{}finally{}块中进行,并且将释放锁的操作放在finally 块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用 Lock来进行同步的话,是以下面这种形式去使用的:

1
2
3
4
5
6
7
8
9
Lock lock = ...;// 具体实现类
lock.lock(); // 上锁
try {
// 处理任务
} catch(Exception e) {
// 处理异常
} finally {
lock.unlock(); // 释放锁
}

如果要预防可能发生的死锁,可以尝试使用下面这个方法:tryLock(long time, TimeUnit unit)方法

2、tryLock()方法 与 tryLock(long time, TimeUnit unit)方法

tryLock()方法:尝试获取锁,返回一个boolean值

tryLock(long time, TimeUnit unit)方法:尝试获取锁,可以设置超时

这是一个比单纯lock()更具有工程价值的方法,如果大家阅读过JDK的一些内部代码,就不难发现,tryLock()在JDK内部被大量的使用。

Lock可以通过这两个方法拿到当前线程的锁的状态,并且可以数组超时时间。并根据当前线程是否获得锁,做不同的选择:如果成功获取锁,….,如果获取失败,…..

与lock()相比,tryLock()至少有下面一个好处:

  • 可以不用进行无限等待。直接打破形成死锁的条件。如果一段时间等不到锁,可以直接放弃,同时释放自己已经得到的资源。这样,就可以在很大程度上,避免死锁的产生。因为线程之间出现了一种谦让机制。(这也是解决死锁问题的一种方案)
  • 可以在应用程序这层进行进行自旋,你可以自己决定尝试几次,或者是放弃。
  • 等待锁的过程中可以响应中断interrupt,如果此时,程序正好收到关机信号,中断就会触发,进入中断异常后,线程就可以做一些清理工作,从而防止在终止程序时出现数据写坏,数据丢失等悲催的情况。
  • 对于tryLock(空参)来说特别适合在应用层自己对锁进行管理,在应用层进行自旋等待。
3、newCondition()方法

lock可以通过newCondition()方法获得一个Condition对象。

关键字 synchronized 与 wait()/notify()这两个方法一起使用可以实现等待/通知模式, Lock 锁通过 newContition()方法返回 Condition 对象,Condition 类也可以实现等待/通知模式。

用 notify()通知时,JVM 会随机唤醒某个等待的线程, 使用 Condition 类可以进行选择性通知, Condition 比较常用的三个方法:

  • await():会使当前线程等待,同时会释放锁,当其他线程调用 signal()时,线程会重新获得锁并继续执行。
  • signal():用于唤醒一个等待的线程。
  • signaAll():用于唤醒所有等待的线程。

==注意:在调用 Condition 的 await()/signal()方法前,也需要线程持有相关的 Lock 锁,调用 await()后线程会释放这个锁,在 singal()调用后会从当前Condition 对象的等待队列中,唤醒 一个线程,唤醒的线程尝试获得锁, 一旦获得锁成功就继续执行。==

3、Lock接口的实现类——ReentrantLock(重点)

ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。

ReentrantLock 是唯一实现了 Lock 接口的类,并且 ReentrantLock 提供了更多的方法。

相对于 synchronized 它具备如下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量

与 synchronized 一样,都支持可重入

1、BAT大厂的面试问题
  • 什么是可重入,什么是可重入锁?它用来解决什么问题?
  • ReentrantLock的核心是AQS,那么它怎么来实现的,继承吗?说说其类内部结构关系。
  • ReentrantLock是如何实现公平锁的?
  • ReentrantLock是如何实现非公平锁的?
  • ReentrantLock默认实现的是公平还是非公平锁?
  • 使用ReentrantLock实现公平和非公平锁的示例?
  • ReentrantLock和Synchronized的对比?
2、ReentrantLock源码分析
1、类的继承关系

ReentrantLock实现了Lock接口,Lock接口中定义了lock与unlock相关操作,并且还存在newCondition方法,表示生成一个Condition条件。

1
public class ReentrantLock implements Lock, java.io.Serializable
2、类的内部类

ReentrantLock总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系:

image

说明:ReentrantLock类内部总共存在SyncNonfairSyncFairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。

  • Sync类

    • 源码

      • abstract static class Sync extends AbstractQueuedSynchronizer {
            // 序列号
            private static final long serialVersionUID = -5179523762034025860L;
        
            // 获取锁
            abstract void lock();
        
            // 非公平方式获取
            final boolean nonfairTryAcquire(int acquires) {
                // 当前线程
                final Thread current = Thread.currentThread();
                // 获取状态
                int c = getState();
                if (c == 0) { // 表示没有线程正在竞争该锁
                    if (compareAndSetState(0, acquires)) { // 比较并设置状态成功,状态0表示锁没有被占用,这里体现了非公平性: 不去检查 AQS 队列
                        // 设置当前线程独占
                        setExclusiveOwnerThread(current); 
                        return true; // 成功
                    }
                }
                // 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入
                else if (current == getExclusiveOwnerThread()) { // 当前线程拥有该锁
                    int nextc = c + acquires; // 增加重入次数
                    if (nextc < 0) // overflow
                        throw new Error("Maximum lock count exceeded");
                    // 设置状态
                    setState(nextc); 
                    // 成功
                    return true; 
                }
                // 失败,回到调用处
                return false;
            }
        
            // 试图在共享模式下获取对象状态,此方法应该查询是否允许它在共享模式下获取对象状态,如果允许,则获取它
            protected final boolean tryRelease(int releases) {
                // state--
                int c = getState() - releases;
                if (Thread.currentThread() != getExclusiveOwnerThread()) // 当前线程不为独占线程
                    throw new IllegalMonitorStateException(); // 抛出异常
                // 释放标识
                boolean free = false; 
                // 支持锁重入, 只有 state 减为 0, 才释放成功
                if (c == 0) {
                    free = true;
                    // 已经释放,清空独占
                    setExclusiveOwnerThread(null); 
                }
                // 设置标识
                setState(c); 
                return free; 
            }
        
            // 判断资源是否被当前线程占有
            protected final boolean isHeldExclusively() {
                // While we must in general read state before owner,
                // we don't need to do so to check if current thread is owner
                return getExclusiveOwnerThread() == Thread.currentThread();
            }
        
            // 新生一个条件
            final ConditionObject newCondition() {
                return new ConditionObject();
            }
        
            // Methods relayed from outer class
            // 返回资源的占用线程
            final Thread getOwner() {        
                return getState() == 0 ? null : getExclusiveOwnerThread();
            }
            // 返回状态
            final int getHoldCount() {            
                return isHeldExclusively() ? getState() : 0;
            }
        
            // 资源是否被占用
            final boolean isLocked() {        
                return getState() != 0;
            }
        
            /**
                * Reconstitutes the instance from a stream (that is, deserializes it).
                */
            // 自定义反序列化逻辑
            private void readObject(java.io.ObjectInputStream s)
                throws java.io.IOException, ClassNotFoundException {
                s.defaultReadObject();
                setState(0); // reset to unlocked state
            }
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31

        - Sync类存在如下方法和作用如下:

        - ![image](JUC/java-thread-x-juc-reentrantlock-2.png)

        - NonfairSync类

        - NonfairSync类继承了Sync类,表示采用非公平策略获取锁,其实现了Sync类中抽象的lock方法,源码如下:

        - ```java
        // 非公平锁
        static final class NonfairSync extends Sync {
        // 版本号
        private static final long serialVersionUID = 7316153563782823691L;

        // 获得锁
        final void lock() {
        // 首先用 cas 尝试(仅尝试一次)将 state 从 0 改为 1, 如果成功表示获得了独占锁
        if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
        // 把当前线程设置独占了锁
        setExclusiveOwnerThread(Thread.currentThread());
        else // 锁已经被占用,或者set失败
        // 以独占模式获取对象,忽略中断
        // 如果尝试失败,进入 AQS的acquire方法
        acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
        }
        }
      • 说明:从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。

    • FairSyn类

      • FairSync类也继承了Sync类,表示采用公平策略获取锁,其实现了Sync类中的抽象lock方法,源码如下:

        • // 公平锁
          static final class FairSync extends Sync {
              // 版本序列化
              private static final long serialVersionUID = -3000897897090466540L;
          
              final void lock() {
                  // 以独占模式获取对象,忽略中断
                  acquire(1);
              }
          
              /**
                  * Fair version of tryAcquire.  Don't grant access unless
                  * recursive call or no waiters or is first.
                  */
              // 尝试公平获取锁
              protected final boolean tryAcquire(int acquires) {
                  // 获取当前线程
                  final Thread current = Thread.currentThread();
                  // 获取状态
                  int c = getState();
                  if (c == 0) { // 状态为0
                      if (!hasQueuedPredecessors() &&
                          compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
                          // 设置当前线程独占
                          setExclusiveOwnerThread(current);
                          return true;
                      }
                  }
                  else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
                      // 下一个状态
                      int nextc = c + acquires;
                      if (nextc < 0) // 超过了int的表示范围
                          throw new Error("Maximum lock count exceeded");
                      // 设置状态
                      setState(nextc);
                      return true;
                  }
                  return false;
              }
          }
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19

          - 说明:

          - 跟踪lock方法的源码可知,当资源空闲时,它总是会先判断sync队列(AbstractQueuedSynchronizer中的数据结构)是否有等待时间更长的线程,如果存在,则将该线程加入到等待队列的尾部,实现了公平获取原则。
          - 其中,FairSync类的lock的方法调用如下,只给出了主要的方法。
          - ![image](JUC/java-thread-x-juc-reentrantlock-3.png)
          - 说明:可以看出只要资源被其他线程占用,该线程就会添加到sync queue中的尾部,而不会先尝试获取资源。这也是和Nonfair最大的区别,Nonfair每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。

          ###### 3、类的属性

          ReentrantLock类的sync非常重要,对ReentrantLock类的操作大部分都直接转化为对Sync和AbstractQueuedSynchronizer类的操作。

          ```java
          public class ReentrantLock implements Lock, java.io.Serializable {
          // 序列号
          private static final long serialVersionUID = 7373984872572414699L;
          // 同步队列
          private final Sync sync;
          }
4、类的构造函数(默认是采用的非公平策略获取锁)
  • ReentrantLock()型构造函数(默认是采用的非公平策略获取锁)

    • public ReentrantLock() {
          // 默认非公平策略
          sync = new NonfairSync();
      }
      
      1
      2
      3
      4
      5
      6
      7

      - ReentrantLock(boolean)型构造函数(可以传递参数确定采用公平策略或者是非公平策略,参数为true表示公平策略,否则,采用非公平策略)

      - ```java
      public ReentrantLock(boolean fair) {
      sync = fair ? new FairSync() : new NonfairSync();
      }
5、核心函数分析——加锁与解锁

加锁与解锁:(默认为非公平锁实现)(配合上面的源码进行分析)

  • 没有竞争时

    • image-20210812043816368
  • 第一个竞争出现时

    • image-20210812043900546

    • Thread-1 执行了

      1. CAS 尝试将 state 由 0 改为 1,结果失败
      2. 进入 tryAcquire 逻辑,这时 state 已经是1,结果仍然失败
      3. 接下来进入 addWaiter 逻辑,构造 Node 队列
        • 图中黄色三角表示该 Node 的 waitStatus 状态,其中 0 为默认正常状态
        • Node 的创建是懒惰的
        • 其中第一个 Node 称为 Dummy(哑元)或哨兵,用来占位,并不关联线程
        • image-20210812044213292
    • 当前线程进入 acquireQueued 逻辑

      1. acquireQueued 会在一个死循环中不断尝试获得锁,失败后进入 park 阻塞

      2. 如果自己是紧邻着 head(排第二位),那么再次 tryAcquire 尝试获取锁,当然这时 state 仍为 1,失败

      3. 进入 shouldParkAfterFailedAcquire 逻辑,将前驱 node,即 head 的 waitStatus 改为 -1,这次返回 false.(waitStatue为-1表示该结点有责任唤醒它的后继结点)

        image-20210812044725103

      4. shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,当然这时state 仍为 1,失败

      5. 当再次进入 shouldParkAfterFailedAcquire 时,这时因为其前驱 node 的 waitStatus 已经是 -1,这次返回true

      6. 进入 parkAndCheckInterrupt, Thread-1 park(灰色表示)

        image-20210812045121967

  • 再次有多个线程经历上述过程竞争失败,变成这个样子

    image-20210812045347108

  • Thread-0 释放锁,进入 tryRelease 流程,如果成功

    • 设置 exclusiveOwnerThread 为 null
    • state = 0

    image-20210812045538702

  • 当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor 流程

  • 找到队列中离 head 最近的一个 Node(没取消的),unpark 恢复其运行,本例中即为 Thread-1

  • 回到 Thread-1 的 acquireQueued 流程

    image-20210812045816095

  • 如果加锁成功(没有竞争),会设置

    • exclusiveOwnerThread 为 Thread-1,state = 1
    • head 指向刚刚 Thread-1 所在的 Node,该 Node 清空 Thread
    • 原本的 head 因为从链表断开,而可被垃圾回收
  • 如果这时候有其它线程来竞争(非公平的体现),例如这时有 Thread-4 来了

    image-20210812050213728

  • 如果不巧又被 Thread-4 占了先

    • Thread-4 被设置为 exclusiveOwnerThread,state = 1
    • Thread-1 再次进入 acquireQueued 流程,获取锁失败,重新进入 park 阻塞
6、核心函数分析——可重入原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
static final class NonfairSync extends Sync {
// ...
// Sync 继承过来的方法, 方便阅读, 放在此处
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入
else if (current == getExclusiveOwnerThread()) {
// state++
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded"); setState(nextc);
return true;
}
return false;
}

// Sync 继承过来的方法, 方便阅读, 放在此处
protected final boolean tryRelease(int releases) {
// state--
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 支持锁重入, 只有 state 减为 0, 才释放成功
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
}
7、核心函数分析——可打断原理

不可打断模式

在此模式下,即使它被打断,仍会驻留在 AQS 队列中,一直要等到获得锁后方能得知自己被打断了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Sync 继承自 AQS
static final class NonfairSync extends Sync {
// ...
private final boolean parkAndCheckInterrupt() {
// 如果打断标记已经是 true, 则 park 会失效
LockSupport.park(this);
// interrupted 会清除打断标记
return Thread.interrupted();
}

final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
// 还是需要获得锁后, 才能返回打断状态
return interrupted;
}
if(shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()
) {
// 如果是因为 interrupt 被唤醒, 返回打断状态为 true
interrupted = true;
}
}
} finally {
if (failed)
cancelAcquire(node);
}
}

public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
){
// 如果打断状态为 true
selfInterrupt();
}
}

static void selfInterrupt() {
// 重新产生一次中断
Thread.currentThread().interrupt();
}
}

可打断模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static final class NonfairSync extends Sync {
public final void acquireInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException(); // 如果没有获得到锁, 进入 ㈠
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}

// ㈠ 可打断的获取锁流程
private void doAcquireInterruptibly(int arg) throws InterruptedException {
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) {
// 在 park 过程中如果被 interrupt 会进入此
// 这时候抛出异常, 而不会再次进入 for (;;)
throw new InterruptedException();
}
}
} finally {
if (failed)
cancelAcquire(node);
}
}
}

通过分析ReentrantLock的源码,可知对其操作都转化为对Sync对象的操作,由于Sync继承了AQS,所以基本上都可以转化为对AQS的操作。如将ReentrantLock的lock函数转化为对Sync的lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到Sync的不同子类。

所以可知,在ReentrantLock的背后,是AQS对其服务提供了支持,下面还是通过例子来更进一步分析源码。

8、核心函数分析——条件变量实现原理

每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject

await 流程

  • 开始 Thread-0 持有锁,调用 await,进入 ConditionObject 的 addConditionWaiter 流程

  • 创建新的 Node 状态为 -2(Node.CONDITION),关联 Thread-0,加入等待队列尾部

    image-20210812233522165

  • 接下来进入 AQS 的 fullyRelease 流程,释放同步器上的锁(为什么调用fullyRelease而不是调用realease:因为该线程可能有重入锁,调用fullyRelease可以将该线程所占的所有锁全部释放掉)

    image-20210812233738920

  • unpark AQS 队列中的下一个节点,竞争锁,假设没有其他竞争线程,那么 Thread-1 竞争成功

    image-20210812234025126

  • park 阻塞 Thread-0

    image-20210812234152831

这里其实是thread0 unpark thread1,但此时1并没有竞争到锁因为0还持有锁,等到thread0 park自己时,1才竞争到锁,因为unpark和park可以互换顺序

signal 流程

  • 假设 Thread-1 要来唤醒 Thread-0

    image-20210812234448884

  • 进入 ConditionObject 的 doSignal 流程,取得等待队列中第一个 Node,即 Thread-0 所在 Node

    image-20210812234608451

  • 执行 transferForSignal 流程,将该 Node 加入 AQS 队列尾部,将 Thread-0 的 waitStatus 改为 0,Thread-3 的waitStatus 改为 -1

    image-20210812234954747

  • Thread-1 释放锁,进入 unlock 流程,略

3、示例分析
公平锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class MyThread extends Thread {
private Lock lock;
public MyThread(String name, Lock lock) {
super(name);
this.lock = lock;
}

public void run () {
lock.lock();
try {
System.out.println(Thread.currentThread() + " running");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
}

public class AbstractQueuedSynchonizerDemo {
public static void main(String[] args) throws InterruptedException {
Lock lock = new ReentrantLock(true);

MyThread t1 = new MyThread("t1", lock);
MyThread t2 = new MyThread("t2", lock);
MyThread t3 = new MyThread("t3", lock);
t1.start();
t2.start();
t3.start();
}
}

运行结果(某一次):

1
2
3
Thread[t1,5,main] running
Thread[t2,5,main] running
Thread[t3,5,main] running

说明:该示例使用的是公平策略,由结果可知,可能会存在如下一种时序。

image

说明:首先,t1线程的lock操作 -> t2线程的lock操作 -> t3线程的lock操作 -> t1线程的unlock操作 -> t2线程的unlock操作 -> t3线程的unlock操作。根据这个时序图来进一步分析源码的工作流程:

  • t1线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由调用流程可知,t1线程成功获取了资源,可以继续执行。
  • t2线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最后的结果是t2线程会被禁止,因为调用了LockSupport.park。
  • t3线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最后的结果是t3线程会被禁止,因为调用了LockSupport.park。
  • t1线程调用了lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:如上图所示,最后,head的状态会变为0,t2线程会被unpark,即t2线程可以继续运行。此时t3线程还是被禁止。
  • t2获得cpu资源,继续运行,由于t2之前被park了,现在需要恢复之前的状态,下图给出了方法调用中的主要方法:
    • image
    • 说明:在setHead函数中会将head设置为之前head的下一个结点,并且将pre域与thread域都设置为null,在acquireQueued返回之前,sync queue就只有两个结点了。
  • t2执行lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最终unpark t3线程,让t3线程可以继续运行。
  • t3线程获取cpu资源,恢复之前的状态,继续运行。
    • image
    • 说明:最终达到的状态是sync queue中只剩下了一个结点,并且该节点除了状态为0外,其余均为null。
  • t3执行lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:最后的状态和之前的状态是一样的,队列中有一个空节点,头结点为尾节点均指向它。

6、ReadWriteLock接口

ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。

在ReadWriteLock接口里面只定义了两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface ReadWriteLock {
/**
* Returns the lock used for reading.
*
* @return the lock used for reading.
*/
Lock readLock();

/**
* Returns the lock used for writing.
*
* @return the lock used for writing.
*/
Lock writeLock();
}

一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成 2 个锁来分配给线程,从而使得多个线程可以同时进行读操作。下面的ReentrantReadWriteLock 实现了 ReadWriteLock 接口。

1、ReadWriteLock接口实现类——ReentrantReadWriteLock读写锁(重要)

现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源, 就不应该允许其他线程对该资源进行读和写的操作了。类似于数据库中的select ...from ... lock in share mode

针对这种场景,JAVA 的并发包JUC提供了读写锁 ReentrantReadWriteLock, ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁

读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但不能同时存在读写线程,读写互斥,读读共享。

ReentrantReadWriteLock 里面提供了很多丰富的方法,不过最主要的有两个方法:readLock()和 writeLock()用来获取读锁和写锁。

  1. 线程进入读锁的前提条件:
    • 没有其他线程的写锁
    • 没有写请求,或者==有写请求,但调用线程和持有锁的线程是同一个(可重入锁)==。
  2. 线程进入写锁的前提条件:
    • 没有其他线程的读锁
    • 没有其他线程的写锁

而读写锁有以下三个重要的特性:

  1. ==公平选择性==:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
  2. ==重进入==:读锁和写锁都支持线程重进入。
  3. ==锁降级==:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁
1、BAT大厂的面试问题
  • 为了有了ReentrantLock还需要ReentrantReadWriteLock?
  • ReentrantReadWriteLock底层实现原理?
  • ReentrantReadWriteLock底层读写状态如何设计的?
    • 高16位为读锁,低16位为写锁
  • 读锁和写锁的最大数量是多少?
  • 本地线程计数器ThreadLocalHoldCounter是用来做什么的?
  • 缓存计数器HoldCounter是用来做什么的?
  • 写锁的获取与释放是怎么实现的?
  • 读锁的获取与释放是怎么实现的?
  • RentrantReadWriteLock为什么不支持锁升级?
  • 什么是锁的升降级?RentrantReadWriteLock为什么不支持锁升级?
2、ReentrantReadWriteLock数据结构

ReentrantReadWriteLock底层是基于ReentrantLockAbstractQueuedSynchronizer来实现的,所以,ReentrantReadWriteLock的数据结构也依托于AQS的数据结构。

3、ReentrantReadWriteLock源码分析
1、类的继承关系
1
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {}

说明:可以看到,ReentrantReadWriteLock实现了ReadWriteLock接口,ReadWriteLock接口定义了获取读锁和写锁的规范,具体需要实现类去实现;同时其还实现了Serializable接口,表示可以进行序列化,在源代码中可以看到ReentrantReadWriteLock实现了自己的序列化逻辑。

2、类的内部类

ReentrantReadWriteLock有五个内部类,五个内部类之间也是相互关联的。内部类的关系如下图所示:

img

说明:如上图所示,Sync继承自AQS、NonfairSync继承自Sync类、FairSync继承自Sync类;ReadLock实现了Lock接口、WriteLock也实现了Lock接口。

3、内部类——Sync类
  • 类的继承关系

    • abstract static class Sync extends AbstractQueuedSynchronizer {}
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      - 说明:Sync抽象类继承自AQS抽象类,Sync类提供了对ReentrantReadWriteLock的支持。

      - 类的内部类

      - Sync类内部存在两个内部类,分别为HoldCounter和ThreadLocalHoldCounter,其中HoldCounter主要与读锁配套使用,其中,HoldCounter源码如下:

      - ```java
      // 计数器
      static final class HoldCounter {
      // 计数
      int count = 0;
      // Use id, not reference, to avoid garbage retention
      // 获取当前线程的TID属性的值
      final long tid = getThreadId(Thread.currentThread());
      }
    • 说明:HoldCounter主要有两个属性,count和tid,其中count表示某个读线程重入的次数tid表示该线程的tid字段的值,该字段可以用来唯一标识一个线程

    • ThreadLocalHoldCounter的源码如下:

      • // 本地线程计数器
        static final class ThreadLocalHoldCounter
            extends ThreadLocal<HoldCounter> {
            // 重写初始化方法,在没有进行set的情况下,获取的都是该HoldCounter值
            public HoldCounter initialValue() {
                return new HoldCounter();
            }
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26

        - 说明:ThreadLocalHoldCounter重写了ThreadLocal的initialValue方法,ThreadLocal类可以将线程与对象相关联。在没有进行set的情况下,get到的均是initialValue方法里面生成的那个HolderCounter对象。

        - 类的属性

        - ```java
        abstract static class Sync extends AbstractQueuedSynchronizer {
        // 版本序列号
        private static final long serialVersionUID = 6317671515068378041L;
        // 高16位为读锁,低16位为写锁
        static final int SHARED_SHIFT = 16;
        // 读锁单位
        static final int SHARED_UNIT = (1 << SHARED_SHIFT);
        // 读锁最大数量
        static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
        // 写锁最大数量
        static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
        // 本地线程计数器
        private transient ThreadLocalHoldCounter readHolds;
        // 缓存的计数器
        private transient HoldCounter cachedHoldCounter;
        // 第一个读线程
        private transient Thread firstReader = null;
        // 第一个读线程的计数
        private transient int firstReaderHoldCount;
        }
    • 说明:该属性中包括了读锁写锁线程的最大量本地线程计数器等。

  • 类的构造函数

    • // 构造函数
      Sync() {
          // 本地线程计数器
          readHolds = new ThreadLocalHoldCounter();
          // 设置AQS的状态
          setState(getState()); // ensures visibility of readHolds
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 说明:在Sync的构造函数中**设置了本地线程计数器和AQS的状态state**。

      ###### 4、内部类——Sync核心函数分析

      对ReentrantReadWriteLock对象的操作绝大多数都转发至Sync对象进行处理。下面对Sync类中的重点函数进行分析:

      - sharedCount函数

      - 表示**占有读锁的线程数量**,源码如下:

      - ```java
      static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
    • 说明:直接将state右移16位,就可以得到读锁的线程数量,因为state的高16位表示读锁,对应的低十六位表示写锁数量

  • exclusiveCount函数

    • 表示占有写锁的线程数量,源码如下:

    • static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26

      - 说明:直接将状态state和(2^16 - 1)做与运算,其等效于将state模上2^16。**写锁数量由state的低十六位表示**。

      - tryRelease函数

      - ```java
      /*
      * Note that tryRelease and tryAcquire can be called by
      * Conditions. So it is possible that their arguments contain
      * both read and write holds that are all released during a
      * condition wait and re-established in tryAcquire.
      */

      protected final boolean tryRelease(int releases) {
      // 判断是否伪独占线程
      if (!isHeldExclusively())
      throw new IllegalMonitorStateException();
      // 计算释放资源后的写锁的数量
      int nextc = getState() - releases;
      // 因为可重入的原因, 写锁计数为 0, 才算释放成功
      boolean free = exclusiveCount(nextc) == 0; // 是否释放成功
      if (free)
      setExclusiveOwnerThread(null); // 设置独占线程为空
      setState(nextc); // 设置状态
      return free;
      }
    • 说明:此函数用于释放写锁资源:首先会判断该线程是否为独占线程,若不为独占线程,则抛出异常,否则,计算释放资源后的写锁的数量,若为0,表示成功释放,资源不将被占用,否则,表示资源还被占用。其函数流程图如下:

    • img

  • tryAcquire函数

    • protected final boolean tryAcquire(int acquires) {
          /*
              * Walkthrough:
              * 1. If read count nonzero or write count nonzero
              *    and owner is a different thread, fail.
              * 2. If count would saturate, fail. (This can only
              *    happen if count is already nonzero.)
              * 3. Otherwise, this thread is eligible for lock if
              *    it is either a reentrant acquire or
              *    queue policy allows it. If so, update state
              *    and set owner.
              */
          // 获取当前线程
          Thread current = Thread.currentThread();
          // 获取状态
          // 获得低 16 位, 代表写锁的 state 计数
          int c = getState();
          // 写线程数量
          int w = exclusiveCount(c);
          if (c != 0) { // 状态不为0
              // (Note: if c != 0 and w == 0 then shared count != 0)
              // 写线程数量为0或者当前线程没有占有独占资源
              if (
                  // c != 0 and w == 0 表示有读锁, 或者
                  w == 0 || 
                  // 如果 exclusiveOwnerThread 不是自己(可重入)
                  current != getExclusiveOwnerThread()) 
                  // 获得锁失败
                  return false;
              // 写锁计数超过低 16 位, 报异常
              if (w + exclusiveCount(acquires) > MAX_COUNT) // 判断是否超过最高写线程数量
                  throw new Error("Maximum lock count exceeded");
              // Reentrant acquire
              // 设置AQS状态
              // 写锁重入, 获得锁成功
              setState(c + acquires);
              return true;
          }
          if (
              // 判断写锁是否该阻塞, 或者
              writerShouldBlock() ||
              // 尝试更改计数失败
              !compareAndSetState(c, c + acquires))
              // 获得锁失败
              return false;
          // 设置独占线程
          // 获得锁成功
          setExclusiveOwnerThread(current);
          return true;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
          
      - 说明:此函数用于**获取写锁**:首先会获取state,判断是否为0,若为0,表示此时没有读锁线程,再判断写线程是否应该被阻塞,而**在非公平策略下总是不会被阻塞,在公平策略下会进行判断(判断同步队列中是否有等待时间更长的线程,若存在,则需要被阻塞,否则,无需阻塞**),之后在设置状态state,然后返回true。若state不为0,则表示此时存在读锁或写锁线程,若写锁线程数量为0或者当前线程为独占锁线程,则返回false,表示不成功,否则,判断写锁线程的重入次数是否大于了最大值,若是,则抛出异常,否则,设置状态state,返回true,表示成功。其函数流程图如下:

      - ![img](JUC/java-thread-x-readwritelock-3.png)

      - tryReleaseShared函数

      - ```java
      protected final boolean tryReleaseShared(int unused) {
      // 获取当前线程
      Thread current = Thread.currentThread();
      if (firstReader == current) { // 当前线程为第一个读线程
      // assert firstReaderHoldCount > 0;
      if (firstReaderHoldCount == 1) // 读线程占用的资源数为1
      firstReader = null;
      else // 减少占用的资源
      firstReaderHoldCount--;
      } else { // 当前线程不为第一个读线程
      // 获取缓存的计数器
      HoldCounter rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
      // 获取当前线程对应的计数器
      rh = readHolds.get();
      // 获取计数
      int count = rh.count;
      if (count <= 1) { // 计数小于等于1
      // 移除
      readHolds.remove();
      if (count <= 0) // 计数小于等于0,抛出异常
      throw unmatchedUnlockException();
      }
      // 减少计数
      --rh.count;
      }
      for (;;) { // 无限循环
      // 获取状态
      int c = getState();
      // 获取状态
      int nextc = c - SHARED_UNIT;
      if (compareAndSetState(c, nextc)) // 比较并进行设置
      // Releasing the read lock has no effect on readers,
      // but it may allow waiting writers to proceed if
      // both read and write locks are now free.
      // 读锁的计数不会影响其它获取读锁线程, 但会影响其它获取写锁线程
      // 计数为 0 才是真正释放
      return nextc == 0;
      }
      }
    • 说明:此函数表示读锁线程释放锁:首先判断当前线程是否为第一个读线程firstReader,若是,则判断第一个读线程占有的资源数firstReaderHoldCount是否为1,若是,则设置第一个读线程firstReader为空,否则,将第一个读线程占有的资源数firstReaderHoldCount减1;若当前线程不是第一个读线程,那么首先会获取缓存计数器(上一个读锁线程对应的计数器 ),若计数器为空或者tid不等于当前线程的tid值,则获取当前线程的计数器,如果计数器的计数count小于等于1,则移除当前线程对应的计数器,如果计数器的计数count小于等于0,则抛出异常,之后再减少计数即可。无论何种情况,都会进入无限循环,该循环可以确保成功设置状态state。其流程图如下:

    • img

  • tryAcquireShared函数

    • private IllegalMonitorStateException unmatchedUnlockException() {
          return new IllegalMonitorStateException(
              "attempt to unlock read lock, not locked by current thread");
      }
      
      // 共享模式下获取资源
      protected final int tryAcquireShared(int unused) {
          /*
              * Walkthrough:
              * 1. If write lock held by another thread, fail.
              * 2. Otherwise, this thread is eligible for
              *    lock wrt state, so ask if it should block
              *    because of queue policy. If not, try
              *    to grant by CASing state and updating count.
              *    Note that step does not check for reentrant
              *    acquires, which is postponed to full version
              *    to avoid having to check hold count in
              *    the more typical non-reentrant case.
              * 3. If step 2 fails either because thread
              *    apparently not eligible or CAS fails or count
              *    saturated, chain to version with full retry loop.
              */
          // 获取当前线程
          Thread current = Thread.currentThread();
          // 获取状态
          int c = getState();
          // 如果是其它线程持有写锁, 获取读锁失败
          if (exclusiveCount(c) != 0 &&
              getExclusiveOwnerThread() != current) // 写线程数不为0并且占有资源的不是当前线程
              return -1;
          // 读锁数量
          int r = sharedCount(c);
          if (// 读线程是否应该被阻塞、并且小于最大值、并且比较设置成功
              // 读锁不该阻塞(如果老二是写锁,读锁该阻塞), 并且
              !readerShouldBlock() &&
              // 小于读锁计数, 并且
              r < MAX_COUNT &&
              // 尝试增加计数成功
              compareAndSetState(c, c + SHARED_UNIT)) { 
              if (r == 0) { // 读锁数量为0
                  // 设置第一个读线程
                  firstReader = current;
                  // 读线程占用的资源数为1
                  firstReaderHoldCount = 1;
              } else if (firstReader == current) { // 当前线程为第一个读线程
                  // 占用资源数加1
                  firstReaderHoldCount++;
              } else { // 读锁数量不为0并且不为当前线程
                  // 获取计数器
                  HoldCounter rh = cachedHoldCounter;
                  if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
                      // 获取当前线程对应的计数器
                      cachedHoldCounter = rh = readHolds.get();
                  else if (rh.count == 0) // 计数为0
                      // 设置
                      readHolds.set(rh);
                  rh.count++;
              }
              return 1;
          }
          return fullTryAcquireShared(current);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
          
      - 说明:此函数表示**读锁线程获取读锁**。首先判断写锁是否为0并且当前线程不占有独占锁,直接返回;否则,判断读线程是否需要被阻塞并且读锁数量是否小于最大值并且比较设置状态成功,若当前没有读锁,则设置第一个读线程firstReader和firstReaderHoldCount;若当前线程线程为第一个读线程,则增加firstReaderHoldCount;否则,将设置当前线程对应的HoldCounter对象的值。流程图如下:

      - ![img](JUC/java-thread-x-readwritelock-5.png)

      - fullTryAcquireShared函数

      - ```java
      // 非公平锁 readerShouldBlock 看 AQS 队列中第一个节点是否是写锁
      // true 则该阻塞, false 则不阻塞
      final boolean readerShouldBlock() {
      return apparentlyFirstQueuedIsExclusive();
      }

      // 与 tryAcquireShared 功能类似, 但会不断尝试 for (;;) 获取读锁, 执行过程中无阻塞
      final int fullTryAcquireShared(Thread current) {
      /*
      * This code is in part redundant with that in
      * tryAcquireShared but is simpler overall by not
      * complicating tryAcquireShared with interactions between
      * retries and lazily reading hold counts.
      */
      HoldCounter rh = null;
      for (;;) { // 无限循环
      // 获取状态
      int c = getState();
      if (exclusiveCount(c) != 0) { // 写线程数量不为0
      if (getExclusiveOwnerThread() != current) // 不为当前线程
      return -1;
      // else we hold the exclusive lock; blocking here
      // would cause deadlock.
      } else if (readerShouldBlock()) { // 写线程数量为0并且读线程被阻塞
      // Make sure we're not acquiring read lock reentrantly
      if (firstReader == current) { // 当前线程为第一个读线程
      // assert firstReaderHoldCount > 0;
      } else { // 当前线程不为第一个读线程
      if (rh == null) { // 计数器不为空
      //
      rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current)) { // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
      rh = readHolds.get();
      if (rh.count == 0)
      readHolds.remove();
      }
      }
      if (rh.count == 0)
      return -1;
      }
      }
      if (sharedCount(c) == MAX_COUNT) // 读锁数量为最大值,抛出异常
      throw new Error("Maximum lock count exceeded");
      if (compareAndSetState(c, c + SHARED_UNIT)) { // 比较并且设置成功
      if (sharedCount(c) == 0) { // 读线程数量为0
      // 设置第一个读线程
      firstReader = current;
      //
      firstReaderHoldCount = 1;
      } else if (firstReader == current) {
      firstReaderHoldCount++;
      } else {
      if (rh == null)
      rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current))
      rh = readHolds.get();
      else if (rh.count == 0)
      readHolds.set(rh);
      rh.count++;
      cachedHoldCounter = rh; // cache for release
      }
      return 1;
      }
      }
      }
    • 说明:在tryAcquireShared函数中,如果下列三个条件不满足:

      • 读线程是否应该被阻塞
      • 小于最大值
      • 比较设置成功
    • 则会进行fullTryAcquireShared函数中,它用来保证相关操作可以成功。其逻辑与tryAcquireShared逻辑类似,不再累赘。

  • doAcquireShared()函数

    • private void doAcquireShared(int arg) {
          // 将当前线程关联到一个 Node 对象上, 模式为共享模式
          final Node node = addWaiter(Node.SHARED);
          boolean failed = true;
          try {
              boolean interrupted = false;
              for (;;) {
                  final Node p = node.predecessor();
                  if (p == head) {
                      // 再一次尝试获取读锁
                      int r = tryAcquireShared(arg); // 成功
                      if (r >= 0) {
                          // ㈠
                          // r 表示可用资源数, 在这里总是 1 允许传播
                          //(唤醒 AQS 中下一个 Share 节点) 
                          setHeadAndPropagate(node, r);
                          p.next = null; // help GC
                          if (interrupted)
                              selfInterrupt();
                          failed = false;
                          return;
                      }
                  }
                  if (
                      // 是否在获取读锁失败时阻塞(前一个阶段 waitStatus == Node.SIGNAL) 
                      shouldParkAfterFailedAcquire(p, node) &&
                      // park 当前线程
                      parkAndCheckInterrupt()
                  ){
                      interrupted = true;
                  }
              }
          }  finally {
              if (failed)
                  cancelAcquire(node);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34



      而其他内部类的操作基本上都是转化到了对Sync对象的操作,在此不再累赘。

      ###### 5、类的属性

      ```java
      public class ReentrantReadWriteLock
      implements ReadWriteLock, java.io.Serializable {
      // 版本序列号
      private static final long serialVersionUID = -6992448646407690164L;
      // 读锁
      private final ReentrantReadWriteLock.ReadLock readerLock;
      // 写锁
      private final ReentrantReadWriteLock.WriteLock writerLock;
      // 同步队列
      final Sync sync;

      private static final sun.misc.Unsafe UNSAFE;
      // 线程ID的偏移地址
      private static final long TID_OFFSET;
      static {
      try {
      UNSAFE = sun.misc.Unsafe.getUnsafe();
      Class<?> tk = Thread.class;
      // 获取线程的tid字段的内存地址
      TID_OFFSET = UNSAFE.objectFieldOffset
      (tk.getDeclaredField("tid"));
      } catch (Exception e) {
      throw new Error(e);
      }
      }
      }

说明:可以看到ReentrantReadWriteLock属性包括了一个ReentrantReadWriteLock.ReadLock对象,表示读锁;一个ReentrantReadWriteLock.WriteLock对象,表示写锁;一个Sync对象,表示同步队列。

6、类的构造函数
  • ReentrantReadWriteLock()型构造函数

    • public ReentrantReadWriteLock() {
          this(false);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14

      - 说明:此构造函数会调用另外一个有参构造函数。

      - ReentrantReadWriteLock(boolean)型构造函数

      - ```java
      public ReentrantReadWriteLock(boolean fair) {
      // 公平策略或者是非公平策略
      sync = fair ? new FairSync() : new NonfairSync();
      // 读锁
      readerLock = new ReadLock(this);
      // 写锁
      writerLock = new WriteLock(this);
      }
    • 说明:可以指定设置公平策略或者非公平策略,并且该构造函数中生成了读锁与写锁两个对象。如果调用的是空参的构造函数,则默认是非公平的策略

7、核心函数分析

对ReentrantReadWriteLock的操作基本上都转化为了对Sync对象的操作,而Sync的函数已经分析过,不再累赘。

8、图解ReentrantReadWriteLock执行流程

读写锁用的是同一个 Sycn 同步器,因此等待队列、state 等也是同一个。

t1 w.lock,t2 r.lock:t1线程为写锁,t2线程为读锁(默认为非公平锁)

  1. t1 成功上锁,流程与 ReentrantLock 加锁相比没有特殊之处,不同是写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位

    image-20210813022016104

  2. t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared 流程。如果有写锁占据,那么 tryAcquireShared 返回 -1 表示失败

    • tryAcquireShared 返回值表示
      • -1 表示失败
      • 0 表示成功,但后继节点不会继续唤醒
      • 正数表示成功,而且数值是还有几个后继节点需要唤醒,读写锁返回 1

    image-20210813023347980

  3. 这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态

    image-20210813023500583

  4. t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁

  5. 如果没有成功,在 doAcquireShared 内 for (;;) 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;;) 循环一次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park

    image-20210813023653011

  6. t3 r.lock,t4 w.lock:t3线程为读锁,t4线程为写锁。这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,就变成了下面的样子(由于t3是读锁,为共享锁,所以状态是Shared,而t4是写锁,为独占锁,所以状态是Ex)这里状态的不同是为了之后的解锁做准备,不同状态的解锁方式不同

    image-20210813023151128

  7. t1 w.unlock:t1线程释放了写锁。这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子

    image-20210813024250098

  8. 接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行

  9. 这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一(其中的readerShouldBlock()方法是区别公平锁和非公平锁的关键,非公平锁不阻塞,公平锁就阻塞)(同样的writerShouldBlock()也是区别写锁公平和非公平的关键)

    image-20210813024836077

  10. 这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

    image-20210813024953061

  11. 事情还没完,在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒老二,这时 t3 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行((setHead(node)之后的代码的意思是,当阻塞队列中有多个连续的读线程时,会传播式地逐一唤醒,if(s.isShared()){doReleaseShared()}这段代码是关键,吧读锁的状态设置成Shared也是为了这里))

    image-20210813025411978

  12. 这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一

    image-20210813025646877

  13. 这时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

    image-20210813030410741

  14. 下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点

  15. t2 r.unlock,t3 r.unlock:t2线程解锁,t3线程解锁:t2 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零

    image-20210813030740325

  16. t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即

    image-20210813031008456

  17. 之后 t4 在 acquireQueued 中 parkAndCheckInterrupt 处恢复运行,再次 for (;;) 这次自己是老二,并且没有其他竞争,tryAcquire(1) 成功,修改头结点,流程结束

    image-20210813031102400

4、ReentrantReadWriteLock示例

下面给出了一个使用ReentrantReadWriteLock的示例,源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import java.util.concurrent.locks.ReentrantReadWriteLock;

class ReadThread extends Thread {
private ReentrantReadWriteLock rrwLock;

public ReadThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.readLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rrwLock.readLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}

class WriteThread extends Thread {
private ReentrantReadWriteLock rrwLock;

public WriteThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.writeLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
} finally {
rrwLock.writeLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}

public class ReentrantReadWriteLockDemo {
public static void main(String[] args) {
ReentrantReadWriteLock rrwLock = new ReentrantReadWriteLock();
ReadThread rt1 = new ReadThread("rt1", rrwLock);
ReadThread rt2 = new ReadThread("rt2", rrwLock);
WriteThread wt1 = new WriteThread("wt1", rrwLock);
rt1.start();
rt2.start();
wt1.start();
}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
rt1 trying to lock
rt2 trying to lock
wt1 trying to lock
rt1 lock successfully
rt2 lock successfully
rt1 unlock successfully
rt2 unlock successfully
wt1 lock successfully
wt1 unlock successfully

说明:程序中生成了一个ReentrantReadWriteLock对象,并且设置了两个读线程,一个写线程。根据结果,可能存在如下的时序图:

img

  • rt1线程执行rrwLock.readLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的状态state为2^16 次方,即表示此时读线程数量为1
  • rt2线程执行rrwLock.readLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的状态state为2 * 2^16次方,即表示此时读线程数量为2
  • wt1线程执行rrwLock.writeLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,在同步队列Sync queue中存在两个结点,并且wt1线程会被禁止运行。
  • rt1线程执行rrwLock.readLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的state为2^16次方,表示还有一个读线程。
  • rt2线程执行rrwLock.readLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:当rt2线程执行unlock操作后,AQS的state为0,并且wt1线程将会被unpark,其获得CPU资源就可以运行。
  • wt1线程获得CPU资源,继续运行,需要恢复。由于之前acquireQueued函数中的parkAndCheckInterrupt函数中被禁止的,所以,恢复到parkAndCheckInterrupt函数中,主要的函数调用如下:
    • img
    • 说明:最后,sync queue队列中只有一个结点,并且头结点尾节点均指向它,AQS的state值为1,表示此时有一个写线程。
  • wt1执行rrwLock.writeLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的state为0,表示没有任何读线程或者写线程了。并且Sync queue结构与上一个状态的结构相同,没有变化。
5、更深入理解
1、什么是锁升降级?

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程

接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,如代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void processData() {
readLock.lock();
if (!update) {
// 必须先释放读锁
readLock.unlock();
// 锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// 准备数据的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 锁降级完成,写锁降级为读锁
}
try {
// 使用数据的流程(略)
} finally {
readLock.unlock();
}
}

上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。

image-20210726213514196

2、锁降级中读锁的获取是否必要呢?

答案是必要的。主要是为了==保证数据的可见性==,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

3、RentrantReadWriteLock支不支持锁升级?

RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是**==保证数据可见性==,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。**

6、使用读写锁实现一致性缓存(保证缓存与数据库数据一致)
1、缓存更新策略

更新时,是先清缓存还是先更新数据库?

先清缓存:

image-20210813015618419

先更新数据库:

image-20210813015913860

补充一种情况,假设查询线程 A 查询数据时恰好缓存数据由于时间到期失效,或是第一次查询

image-20210813021033756

这种情况的出现几率非常小,见 facebook 论文

2、使用读写锁实现一个简单的按需加载缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import java.util.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class TestGenericDao {
public static void main(String[] args) {
GenericDao dao = new GenericDaoCached();
System.out.println("============> 查询");
String sql = "select * from emp where empno = ?";
int empno = 7369;
Emp emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);

System.out.println("============> 更新");
dao.update("update emp set sal = ? where empno = ?", 800, empno);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
}
}

class GenericDaoCached extends GenericDao {
private GenericDao dao = new GenericDao();
private Map<SqlPair, Object> map = new HashMap<>();
private ReentrantReadWriteLock rw = new ReentrantReadWriteLock();

@Override
public <T> List<T> queryList(Class<T> beanClass, String sql, Object... args) {
return dao.queryList(beanClass, sql, args);
}

@Override
public <T> T queryOne(Class<T> beanClass, String sql, Object... args) {
// 先从缓存中找,找到直接返回
SqlPair key = new SqlPair(sql, args);;
rw.readLock().lock();
try {
T value = (T) map.get(key);
if(value != null) {
return value;
}
} finally {
rw.readLock().unlock();
}
rw.writeLock().lock();
try {
// 多个线程
T value = (T) map.get(key);
if(value == null) {
// 缓存中没有,查询数据库
value = dao.queryOne(beanClass, sql, args);
map.put(key, value);
}
return value;
} finally {
rw.writeLock().unlock();
}
}

@Override
public int update(String sql, Object... args) {
rw.writeLock().lock();
try {
// 先更新库
int update = dao.update(sql, args);
// 清空缓存
map.clear();
return update;
} finally {
rw.writeLock().unlock();
}
}

class SqlPair {
private String sql;
private Object[] args;

public SqlPair(String sql, Object[] args) {
this.sql = sql;
this.args = args;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
SqlPair sqlPair = (SqlPair) o;
return Objects.equals(sql, sqlPair.sql) &&
Arrays.equals(args, sqlPair.args);
}

@Override
public int hashCode() {
int result = Objects.hash(sql);
result = 31 * result + Arrays.hashCode(args);
return result;
}
}
}

注意:

  • 以上实现体现的是读写锁的应用,保证缓存和数据库的一致性,但有下面的问题没有考虑
    • 适合读多写少,如果写操作比较频繁,以上实现性能低
    • 没有考虑缓存容量
    • 没有考虑缓存过期
    • 只适合单机
    • 并发性还是低,目前只会用一把锁(其实可以把锁再细化,不同的表用不同的锁)
    • 更新方法太过简单粗暴,清空了所有 key(考虑按类型分区或重新设计 key)
  • 乐观锁实现:用 CAS 去更新
7、ReentrantReadWriteLock总结
  • 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  • 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

原因:当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。

image-20210726213440799

注意事项:

  • 读锁不支持条件变量

  • 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待

    • r.lock();
      try {
          // ...
          w.lock();
          try {
              // ...
          } finally{
              w.unlock();
          }
      } finally{
          r.unlock();
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      - 重入时降级支持:即持有写锁的情况下去获取读锁



      #### 2、对ReentrantReadWriteLock性能再提升——`StampedLock`

      ##### 1、StampedLock概述

      该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是**在使用读锁、写锁时都必须配合【戳】使用**

      **加解读锁**:

      ```java
      long stamp = lock.readLock();
      lock.unlockRead(stamp);

加解写锁

1
2
long stamp = lock.writeLock();
lock.unlockWrite(stamp);

乐观读,StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次 戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。

1
2
3
4
long stamp = lock.tryOptimisticRead(); // 验戳
if(!lock.validate(stamp)){
// 锁升级
}
2、StampedLock示例

提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Slf4j(topic = "c.DataContainerStamped")
class DataContainerStamped {
private int data;
private final StampedLock lock = new StampedLock();

public DataContainerStamped(int data) {
this.data = data;
}

public int read(int readTime) {
long stamp = lock.tryOptimisticRead();
log.debug("optimistic read locking...{}", stamp);
sleep(readTime);
if (lock.validate(stamp)) {
log.debug("read finish...{}, data:{}", stamp, data);
return data;
}
// 锁升级 - 读锁
log.debug("updating to read lock... {}", stamp);
try {
stamp = lock.readLock();
log.debug("read lock {}", stamp);
sleep(readTime);
log.debug("read finish...{}, data:{}", stamp, data);
return data;
} finally {
log.debug("read unlock {}", stamp);
lock.unlockRead(stamp);
}
}

public void write(int newData) {
long stamp = lock.writeLock();
log.debug("write lock {}", stamp);
try {
sleep(2);
this.data = newData;
} finally {
log.debug("write unlock {}", stamp);
lock.unlockWrite(stamp);
}
}
}

测试 读-读 (乐观读)

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j(topic = "c.TestStampedLock")
public class TestStampedLock {
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.read(0);
}, "t2").start();
}
}

输出结果,可以看到实际没有加读锁

1
2
3
4
15:58:50.217 c.DataContainerStamped [t1] - optimistic read locking...256 
15:58:50.717 c.DataContainerStamped [t2] - optimistic read locking...256
15:58:50.717 c.DataContainerStamped [t2] - read finish...256, data:1
15:58:51.220 c.DataContainerStamped [t1] - read finish...256, data:1

测试 读-写 时优化读补加读锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j(topic = "c.TestStampedLock")
public class TestStampedLock {
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.write(100);
}, "t2").start();
}
}

输出结果

1
2
3
4
5
6
7
15:57:00.219 c.DataContainerStamped [t1] - optimistic read locking...256 
15:57:00.717 c.DataContainerStamped [t2] - write lock 384
15:57:01.225 c.DataContainerStamped [t1] - updating to read lock... 256
15:57:02.719 c.DataContainerStamped [t2] - write unlock 384
15:57:02.719 c.DataContainerStamped [t1] - read lock 513
15:57:03.719 c.DataContainerStamped [t1] - read finish...513, data:1000
15:57:03.719 c.DataContainerStamped [t1] - read unlock 513
3、StampedLock是否可以替代ReentrantReadWriteLock

当然是不可以,虽然StampedLock在读写锁上的性能比ReentrantReadWriteLock好,但是它有以下几个缺点:

  • StampedLock 不支持条件变量
  • StampedLock 不支持可重入

7、线程间通信

线程间通信的模型有两种:==共享内存==和==消息传递==

1、场景

我们来基本一道面试常见的题目来分析:场景——四个线程,两个线程对当前数值加 1,另外两个线程对当前数值减 1,要求用线程间通信。

2、分析

1、关于i++与i–的字节码及其执行流程

i++其实是一个复合操作,包括三步骤:

  • 读取i的值。
  • 对i加1。
  • 将i的值写回内存。

i++的相关字节码指令:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
iadd //自增
putstatic i // 将修改后的值存入静态变量i

对于i–也是类似:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
isub //自减
putstatic i // 将修改后的值存入静态变量i

而Java的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:(下图只显示了两个线程,分别做自增和自减)

image-20210804150447015

如果是单线程以上 8 行字节码是顺序执行(不会交错)没有问题:

image-20210804152755128

但多线程下这 8 行字节码可能交错运行:

出现负数的情况:

image-20210804152921570

出现正数的情况:

image-20210804152947980

2、临界区 Critical Section
  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
3、竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

3、解决方法

image-20210804154458987

image-20210804154522110

使用Lock的方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package com.atguigu.lock;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

//第一步 创建资源类,定义属性和操作方法
class Share {
private int number = 0;

//创建Lock
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

//+1
public void incr() throws InterruptedException {
//上锁
lock.lock();
try {
//判断
while (number != 0) {
condition.await();
}
//干活
number++;
System.out.println(Thread.currentThread().getName()+" :: "+number);
//通知
condition.signalAll();
}finally {
//解锁
lock.unlock();
}
}

//-1
public void decr() throws InterruptedException {
lock.lock();
try {
while(number != 1) {
condition.await();
}
number--;
System.out.println(Thread.currentThread().getName()+" :: "+number);
condition.signalAll();
}finally {
lock.unlock();
}
}
}

public class ThreadDemo2 {

public static void main(String[] args) {
Share share = new Share();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.incr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"A").start();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.decr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"B").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.incr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"C").start();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.decr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"D").start();
}
}

8、线程间定制化通信

案例介绍:

问题:A 线程打印 5 次 A,B 线程打印 10 次 B,C 线程打印 15 次 C,按照此顺序循环 10 轮。

实现方法:

image-20210722025741403

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
//第一步 创建资源类
class ShareResource {
//定义标志位
private int flag = 1; // 1 AA 2 BB 3 CC

//创建Lock锁
private Lock lock = new ReentrantLock();

//创建三个condition
private Condition c1 = lock.newCondition();
private Condition c2 = lock.newCondition();
private Condition c3 = lock.newCondition();

//打印5次,参数第几轮
public void print5(int loop) throws InterruptedException {
//上锁
lock.lock();
try {
//判断
while(flag != 1) {
//等待
c1.await();
}
//干活
for (int i = 1; i <=5; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//通知
flag = 2; //修改标志位 2
c2.signal(); //通知BB线程
}finally {
//释放锁
lock.unlock();
}
}

//打印10次,参数第几轮
public void print10(int loop) throws InterruptedException {
lock.lock();
try {
while(flag != 2) {
c2.await();
}
for (int i = 1; i <=10; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//修改标志位
flag = 3;
//通知CC线程
c3.signal();
}finally {
lock.unlock();
}
}

//打印15次,参数第几轮
public void print15(int loop) throws InterruptedException {
lock.lock();
try {
while(flag != 3) {
c3.await();
}
for (int i = 1; i <=15; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//修改标志位
flag = 1;
//通知AA线程
c1.signal();
}finally {
lock.unlock();
}
}
}

public class ThreadDemo3 {
public static void main(String[] args) {
ShareResource shareResource = new ShareResource();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print5(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"AA").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print10(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"BB").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print15(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"CC").start();
}
}

9、集合的线程安全

类结构关系:

image

image-20210813203912863

线程安全集合类可以分为三大类:

  • 遗留的线程安全集合如 Hashtable , Vector
  • 使用 Collections 装饰的线程安全集合,如:
    • Collections.synchronizedCollection
    • Collections.synchronizedList
    • Collections.synchronizedMap
    • Collections.synchronizedSet
    • Collections.synchronizedNavigableMap
    • Collections.synchronizedNavigableSet
    • Collections.synchronizedSortedMap
    • Collections.synchronizedSortedSet
  • java.util.concurrent.*

重点介绍 java.util.concurrent.* 下的线程安全集合类,可以发现它们有规律,里面包含三类关键词:BlockingCopyOnWriteConcurrent

  • Blocking 大部分实现基于锁,并提供用来阻塞的方法
  • CopyOnWrite 之类容器修改开销相对较重
  • Concurrent 类型的容器
    • 内部很多操作使用 cas 优化,一般可以提供较高吞吐量
    • 弱一致性
      • 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
      • 求大小弱一致性,size 操作未必是 100% 准确
      • 读取弱一致性

遍历时如果发生了修改,对于非安全容器来讲,使用 fail-fast 机制也就是让遍历立刻失败,抛出ConcurrentModificationException,不再继续遍历

1、ArrayList不安全

ArrayList的底层没有用synchronized修饰,本身也没有使用CAS等轻量级锁。所以在多线程环境下,ArrayList是不安全的。

示例:(在一边添加一遍读取的时候,可能会出现内容还没有添加进去就被读取的情况,而且会报:java.util.ConcurrentModificationException)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ThreadDemo4 {
public static void main(String[] args) {
//创建ArrayList集合
List<String> list = new ArrayList<>();

for (int i = 0; i <30; i++) {
new Thread(()->{
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Exception in thread "27" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
at java.util.ArrayList$Itr.next(ArrayList.java:851)
at java.util.AbstractCollection.toString(AbstractCollection.java:461)
at java.lang.String.valueOf(String.java:2994)
at java.io.PrintStream.println(PrintStream.java:821)
at test.ThreadDemo.lambda$main$0(ThreadDemo.java:17)
at java.lang.Thread.run(Thread.java:748)
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa, 7d121289]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa, 7d121289]
...

解决ArrayList在多线程环境下不安全的问题:

  1. 方案1:用Vector代替ArrayList
  2. 方案2:Collections.synchronizedList创建一个同步的ArrayList
  3. 方案3:所以JUC的CopyOnWriteArrayList
1、方案1:用Vector代替ArrayList
1
2
// Vector解决
List<String> list = new Vector<>();

在Vector底层的几乎所有方法都有Synchronized进行修饰,所以在多线程下Vector是安全的。

但是这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

2、方案2:Collections.synchronizedList创建一个同步的ArrayList
1
2
//Collections解决
List<String> list = Collections.synchronizedList(new ArrayList<>());

同理这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

那么有没有好的方法,既解决了ArrayList不安全的问题,又不会对程序的效率造成很大的影响?

答:方案3:所以JUC的CopyOnWriteArrayList

1
2
// CopyOnWriteArrayList解决
List<String> list = new CopyOnWriteArrayList<>();

2、JUC的CopyOnWriteArrayList

CopyOnWriteArraySet 是它的马甲 底层实现采用了 写入时拷贝 的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离

1、BAT大厂的面试问题
  • 请先说说非并发集合中Fail-fast机制?
  • 再为什么说ArrayList查询快而增删慢?
  • 对比ArrayList说说CopyOnWriteArrayList的增删改查实现原理?
    • COW基于拷贝
  • 再说下弱一致性的迭代器原理是怎么样的?
    • COWIterator<E>
  • CopyOnWriteArrayList为什么并发安全且性能比Vector好?
  • CopyOnWriteArrayList有何缺陷,说说其应用场景?
2、CopyOnWriteArrayList源码分析
1、类的继承关系

CopyOnWriteArrayList

  • 实现了List接口,List接口定义了对列表的基本操作;
  • 同时实现了RandomAccess接口,表示可以随机访问(数组具有随机访问的特性);
  • 同时实现了Cloneable接口,表示可克隆;
  • 同时也实现了Serializable接口,表示可被序列化。
1
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {}
2、类的内部类——COWIterator类

COWIterator表示迭代器,其也有一个Object类型的数组作为CopyOnWriteArrayList数组的快照,这种快照风格的迭代器方法在创建迭代器时使用了对当时数组状态的引用。此数组在迭代器的生存期内不会更改,因此不可能发生冲突,并且迭代器保证不会抛出 ConcurrentModificationException

创建迭代器以后,迭代器就不会反映列表的添加、移除或者更改。在迭代器上进行的元素更改操作(remove、set 和 add)不受支持。这些方法将抛出 UnsupportedOperationException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
// 快照
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
// 游标
private int cursor;
// 构造函数
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
// 是否还有下一项
public boolean hasNext() {
return cursor < snapshot.length;
}
// 是否有上一项
public boolean hasPrevious() {
return cursor > 0;
}
// next项
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext()) // 不存在下一项,抛出异常
throw new NoSuchElementException();
// 返回下一项
return (E) snapshot[cursor++];
}

@SuppressWarnings("unchecked")
public E previous() {
if (! hasPrevious())
throw new NoSuchElementException();
return (E) snapshot[--cursor];
}

// 下一项索引
public int nextIndex() {
return cursor;
}

// 上一项索引
public int previousIndex() {
return cursor-1;
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code remove}
* is not supported by this iterator.
*/
// 不支持remove操作
public void remove() {
throw new UnsupportedOperationException();
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code set}
* is not supported by this iterator.
*/
// 不支持set操作
public void set(E e) {
throw new UnsupportedOperationException();
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code add}
* is not supported by this iterator.
*/
// 不支持add操作
public void add(E e) {
throw new UnsupportedOperationException();
}

@Override
public void forEachRemaining(Consumer<? super E> action) {
Objects.requireNonNull(action);
Object[] elements = snapshot;
final int size = elements.length;
for (int i = cursor; i < size; i++) {
@SuppressWarnings("unchecked") E e = (E) elements[i];
action.accept(e);
}
cursor = size;
}
}
3、类的属性

属性中有一个可重入锁,用来保证线程安全访问,还有一个Object类型的数组,用来存放具体的元素。当然,也使用到了反射机制CAS来保证原子性的修改lock域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = 8673264195747942595L;
// 可重入锁
final transient ReentrantLock lock = new ReentrantLock();
// 对象数组,用于存放元素
private transient volatile Object[] array;
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// lock域的内存偏移量
private static final long lockOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = CopyOnWriteArrayList.class;
lockOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("lock"));
} catch (Exception e) {
throw new Error(e);
}
}
}
4、类的构造函数
  • 默认构造函数

    • public CopyOnWriteArrayList() {
          // 设置数组
          setArray(new Object[0]);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20

      - `CopyOnWriteArrayList(Collection<? extends E>)`型构造函数——该构造函数用于创建一个按 collection 的迭代器返回元素的顺序包含指定 collection 元素的列表。

      - ```java
      public CopyOnWriteArrayList(Collection<? extends E> c) {
      Object[] elements;
      if (c.getClass() == CopyOnWriteArrayList.class) // 类型相同
      // 获取c集合的数组
      elements = ((CopyOnWriteArrayList<?>)c).getArray();
      else { // 类型不相同
      // 将c集合转化为数组并赋值给elements
      elements = c.toArray();
      // c.toArray might (incorrectly) not return Object[] (see 6260652)
      if (elements.getClass() != Object[].class) // elements类型不为Object[]类型
      // 将elements数组转化为Object[]类型的数组
      elements = Arrays.copyOf(elements, elements.length, Object[].class);
      }
      // 设置数组
      setArray(elements);
      }
    • CopyOnWriteArrayList(Collection<? extends E>)型构造函数的处理流程如下:

      1. 判断传入的集合c的类型是否为CopyOnWriteArrayList类型,若是,则获取该集合类型的底层数组(Object[]),并且设置当前CopyOnWriteArrayList的数组(Object[]数组),进入步骤③;否则,进入步骤②
      2. 将传入的集合转化为数组elements,判断elements的类型是否为Object[]类型(toArray方法可能不会返回Object类型的数组),若不是,则将elements转化为Object类型的数组。进入步骤③
      3. 设置当前CopyOnWriteArrayList的Object[]为elements。
  • CopyOnWriteArrayList(E[])型构造函数

    • 该构造函数用于创建一个保存给定数组的副本的列表。

    • public CopyOnWriteArrayList(E[] toCopyIn) {
          // 将toCopyIn转化为Object[]类型数组,然后设置当前数组
          setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28

      ###### 5、核心函数

      - copyOf
      - add
      - addIfAbsent
      - set
      - remove

      对于CopyOnWriteArrayList的函数分析,主要明白**Arrays.copyOf方法**即可理解CopyOnWriteArrayList其他函数的意义。

      ###### 6、核心函数分析——copyOf

      该函数用于**复制指定的数组,截取或用 null 填充(如有必要),以使副本具有指定的长度**。

      ```java
      public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
      @SuppressWarnings("unchecked")
      // 确定copy的类型(将newType转化为Object类型,将Object[].class转化为Object类型,判断两者是否相等,若相等,则生成指定长度的Object数组
      // 否则,生成指定长度的新类型的数组)
      T[] copy = ((Object)newType == (Object)Object[].class)
      ? (T[]) new Object[newLength]
      : (T[]) Array.newInstance(newType.getComponentType(), newLength);
      // 将original数组从下标0开始,复制长度为(original.length和newLength的较小者),复制到copy数组中(也从下标0开始)
      System.arraycopy(original, 0, copy, 0,
      Math.min(original.length, newLength));
      return copy;
      }
7、核心函数分析——add
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public boolean add(E e) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 元素数组
// 获取旧的数组
Object[] elements = getArray();
// 数组长度
int len = elements.length;
// 复制数组
// 拷贝新的数组(这里是比较耗时的操作,但不影响其它读线程)
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 存放元素e
// 添加新元素
newElements[len] = e;
// 设置数组
// 替换旧的数组
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}

这里的源码版本是 Java 11,在 Java 1.8 中使用的是可重入锁而不是 synchronized

此函数用于将指定元素添加到此列表的尾部,处理流程如下(写时复制技术)(并发读,独立写)

  • 获取锁(保证多线程的安全访问),获取当前的Object数组,获取Object数组的长度为length,进入步骤②。
  • 根据Object数组复制一个长度为length+1的Object数组为newElements(此时,newElements[length]为null),进入下一步骤。
  • 将下标为length的数组元素newElements[length]设置为元素e,再设置当前Object[]为newElements,释放锁,返回。这样就完成了元素的添加。

其实就是写时复制技术,并发读,独立写

  • 读进程读的是原来的版本
  • 写进程写的是原来的版本的复制版本
  • 在写进程完成好写之后,再将复制的版本与原来的版本进行合并

image-20210723213936543

8、核心函数分析——addIfAbsent

该函数用于添加元素(如果数组中不存在,则添加;否则,不添加,直接返回),可以保证多线程环境下不会重复添加元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private boolean addIfAbsent(E e, Object[] snapshot) {
// 重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] current = getArray();
// 数组长度
int len = current.length;
if (snapshot != current) { // 快照不等于当前数组,对数组进行了修改
// Optimize for lost race to another addXXX operation
// 取较小者
int common = Math.min(snapshot.length, len);
for (int i = 0; i < common; i++) // 遍历
if (current[i] != snapshot[i] && eq(e, current[i])) // 当前数组的元素与快照的元素不相等并且e与当前元素相等
// 表示在snapshot与current之间修改了数组,并且设置了数组某一元素为e,已经存在
// 返回
return false;
if (indexOf(e, current, common, len) >= 0) // 在当前数组中找到e元素
// 返回
return false;
}
// 复制数组
Object[] newElements = Arrays.copyOf(current, len + 1);
// 对数组len索引的元素赋值为e
newElements[len] = e;
// 设置数组
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}

该函数的流程如下:

  1. 获取锁,获取当前数组为current,current长度为len,判断数组之前的快照snapshot是否等于当前数组current,若不相等,则进入步骤②;否则,进入步骤④
  2. 不相等,表示在snapshot与current之间,对数组进行了修改(如进行了add、set、remove等操作),获取长度(snapshot与current之间的较小者),对current进行遍历操作,若遍历过程发现snapshot与current的元素不相等并且current的元素与指定元素相等(可能进行了set操作),进入步骤⑤,否则,进入步骤③
  3. 在当前数组中索引指定元素,若能够找到,进入步骤⑤,否则,进入步骤④
  4. 复制当前数组current为newElements,长度为len+1,此时newElements[len]为null。再设置newElements[len]为指定元素e,再设置数组,进入步骤⑤
  5. 释放锁,返回。
9、核心函数分析——set

此函数用于用指定的元素替代此列表指定位置上的元素,也是基于数组的复制来实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public E set(int index, E element) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] elements = getArray();
// 获取index索引的元素
E oldValue = get(elements, index);

if (oldValue != element) { // 旧值不等于element
// 数组长度
int len = elements.length;
// 复制数组
Object[] newElements = Arrays.copyOf(elements, len);
// 重新赋值index索引的值
newElements[index] = element;
// 设置数组
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
// 设置数组
setArray(elements);
}
// 返回旧值
return oldValue;
} finally {
// 释放锁
lock.unlock();
}
}
10、核心函数分析——remove

此函数用于移除此列表指定位置上的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public E remove(int index) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] elements = getArray();
// 数组长度
int len = elements.length;
// 获取旧值
E oldValue = get(elements, index);
// 需要移动的元素个数
int numMoved = len - index - 1;
if (numMoved == 0) // 移动个数为0
// 复制后设置数组
setArray(Arrays.copyOf(elements, len - 1));
else { // 移动个数不为0
// 新生数组
Object[] newElements = new Object[len - 1];
// 复制index索引之前的元素
System.arraycopy(elements, 0, newElements, 0, index);
// 复制index索引之后的元素
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
// 设置索引
setArray(newElements);
}
// 返回旧值
return oldValue;
} finally {
// 释放锁
lock.unlock();
}
}

处理流程如下:

  1. 获取锁,获取数组elements,数组长度为length,获取索引的值elements[index],计算需要移动的元素个数(length - index - 1),若个数为0,则表示移除的是数组的最后一个元素,复制elements数组,复制长度为length-1,然后设置数组,进入步骤③;否则,进入步骤②
  2. 先复制index索引前的元素,再复制index索引后的元素,然后设置数组。
  3. 释放锁,返回旧值。
3、CopyOnWriteArrayList示例

下面通过一个示例来了解CopyOnWriteArrayList的使用:

在程序中,有一个PutThread线程会每隔50ms就向CopyOnWriteArrayList中添加一个元素,并且两次使用了迭代器,迭代器输出的内容都是生成迭代器时,CopyOnWriteArrayList的Object数组的快照的内容,在迭代的过程中,往CopyOnWriteArrayList中添加元素也不会抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;

class PutThread extends Thread {
private CopyOnWriteArrayList<Integer> cowal;

public PutThread(CopyOnWriteArrayList<Integer> cowal) {
this.cowal = cowal;
}

public void run() {
try {
for (int i = 100; i < 110; i++) {
cowal.add(i);
Thread.sleep(50);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public class CopyOnWriteArrayListDemo {
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> cowal = new CopyOnWriteArrayList<Integer>();
for (int i = 0; i < 10; i++) {
cowal.add(i);
}
PutThread p1 = new PutThread(cowal);
p1.start();
Iterator<Integer> iterator = cowal.iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + " ");
}
System.out.println();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}

iterator = cowal.iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + " ");
}
}
}

运行结果(某一次)

1
2
0 1 2 3 4 5 6 7 8 9 100 
0 1 2 3 4 5 6 7 8 9 100 101 102 103
4、CopyOnWriteArrayList的弱一致性体现
1、get 弱一致性

image-20210814045103054

时间点 操作
1 Thread-0 getArray()
2 Thread-1 getArray()
3 Thread-1 setArray(arrayCopy)
4 Thread-0 array[index]
2、迭代器弱一致性
1
2
3
4
5
6
7
8
9
10
11
12
13
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iter = list.iterator();
new Thread(() -> {
list.remove(0);
System.out.println(list);
},"t1").start();
sleep1s();
while (iter.hasNext()) {
System.out.println(iter.next());
}

虽然线程t1已经将1从list中移除,但是迭代器当中迭代的list依旧是旧的list,有包括1

3、关于弱一致性

不要觉得弱一致性就不好

  • 数据库的 MVCC 都是弱一致性的表现
  • 并发高和一致性是矛盾的,需要权衡
5、更深入理解
1、CopyOnWriteArrayList的缺陷和使用场景

CopyOnWriteArrayList 有几个缺点:

  • 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc
  • 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求;

CopyOnWriteArrayList 合适==读多写少==的场景,不过这类慎用

因为谁也没法保证CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次add/set都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。

2、CopyOnWriteArrayList为什么并发安全性能比Vector好?
  • Vector对单独的add,remove等方法都是在方法上加了synchronized;
  • 并且如果一个线程A调用size时,另一个线程B 执行了remove,然后size的值就不是最新的,然后线程A调用remove就会越界(这时就需要再加一个Synchronized)。这样就导致有了双重锁,效率大大降低,何必呢。
  • 于是vector废弃了,要用就用CopyOnWriteArrayList 吧。

3、HashMap不安全

HashMap的底层没有用synchronized修饰,本身也没有使用CAS等轻量级锁。所以在多线程环境下,HashMap是不安全的。

示例:(在一边添加一遍读取的时候,可能会出现内容还没有添加进去就被读取的情况,而且会报:java.util.ConcurrentModificationException)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadDemo4 {
public static void main(String[] args) {
//演示HashMap
Map<String,String> map = new HashMap<>();

for (int i = 0; i <30; i++) {
String key = String.valueOf(i);
new Thread(()->{
//向集合添加内容
map.put(key,UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(map);
},String.valueOf(i)).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{22=b7638976, 23=918c6021, 24=254a542e, 26=effdaef0, 27=b0fd0006, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 8=a8951500, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 28=85c91660, 29=930cd3b4, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 28=85c91660, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
Exception in thread "4" Exception in thread "0" Exception in thread "5" Exception in thread "17" Exception in thread "1" Exception in thread "7" Exception in thread "14" Exception in thread "19" Exception in thread "26" Exception in thread "27" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1437)
at java.util.HashMap$EntryIterator.next(HashMap.java:1471)
at java.util.HashMap$EntryIterator.next(HashMap.java:1469)
at java.util.AbstractMap.toString(AbstractMap.java:554)
at java.lang.String.valueOf(String.java:2994)
at java.io.PrintStream.println(PrintStream.java:821)
at test.ThreadDemo4.lambda$main$0(ThreadDemo4.java:20)
at java.lang.Thread.run(Thread.java:748)

解决HashMap在多线程环境下不安全的问题:

  1. 方案1:用HashTable代替HashMap
  2. 方案2:JUC的ConcurrentHashMap
方案1:用HashTable代替HashMap
1
2
// 用HashTable代替HashMap
Map<String,String> map = new HashTable<>();

在HashTable底层的几乎所有方法都有Synchronized进行修饰,所以在多线程下HashTable是安全的。

但是这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

那么有没有好的方法,既解决了HashMap不安全的问题,又不会对程序的效率造成很大的影响?

答:方案2:所以JUC的ConcurrentHashMap

1
2
// ConcurrentHashMap解决
Map<String,String> map = new ConcurrentHashMap<>();

4、JUC的ConcurrentHashMap

1、BAT大厂的面试问题
  • 为什么HashTable慢?它的并发度是什么?那么ConcurrentHashMap并发度是什么?
  • ConcurrentHashMap在JDK1.7和JDK1.8中实现有什么差别?JDK1.8解決了JDK1.7中什么问题?
  • ConcurrentHashMap JDK1.7实现的原理是什么?
    • 分段锁机制
  • ConcurrentHashMap JDK1.8实现的原理是什么?
    • 数组+链表+红黑树,CAS
  • ConcurrentHashMap JDK1.7中Segment数(concurrencyLevel)默认值是多少?为何一旦初始化就不可再扩容?
  • ConcurrentHashMap JDK1.7说说其put的机制?
  • ConcurrentHashMap JDK1.7是如何扩容的?
    • rehash(注:segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容)
  • ConcurrentHashMap JDK1.8是如何扩容的?
    • tryPresize
  • ConcurrentHashMap JDK1.8链表转红黑树的时机是什么?临界值为什么是8?
  • ConcurrentHashMap JDK1.8是如何进行数据迁移的?
    • transfer
  • JDK 7 HashMap 并发死链问题
2、为什么HashTable慢

Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。

3、ConcurrentHashMap - JDK1.7

在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap.

简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可实现多线程put操作。

  • 优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的
  • 缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化

接下来分析JDK1.7版本中ConcurrentHashMap的实现原理。

1、数据结构

整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表“部分”或“一段”的意思,所以很多地方都会将其描述为分段锁。一般使用“槽”来代表一个 segment。

简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

img

concurrencyLevel:并行级别、并发数、Segment 数,怎么翻译不重要,重要的是理解它。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。

再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。

2、初始化
  • initialCapacity初始容量。这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment
  • loadFactor负载因子。之前我们说了,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
// ssize 必须是 2^n, 即 2, 4, 8, 16 ... 表示了 segments 数组的大小
int sshift = 0;
int ssize = 1;
// 计算并行级别 ssize,因为要保持并行级别是 2 的 n 次方
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 我们这里先不要那么烧脑,用默认值,concurrencyLevel 为 16,sshift 为 4
// segmentShift 默认是 32 - 4 = 28
// 那么计算出 segmentShift 为 28,segmentMask 为 15,即 0000 0000 0000 1111后面会用到这两个值
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;// 掩码

if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;

// initialCapacity 是设置整个 map 初始的大小,
// 这里根据 initialCapacity 计算 Segment 数组中每个位置可以分到的大小
// 如 initialCapacity 为 64,那么每个 Segment 或称之为"槽"可以分到 4 个
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// 默认 MIN_SEGMENT_TABLE_CAPACITY 是 2,这个值也是有讲究的,因为这样的话,对于具体的槽上,
// 插入一个元素不至于扩容,插入第二个的时候才会扩容
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;

// 创建 Segment 数组,
// 并创建数组的第一个元素 segment[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// 往数组写入 segment[0]
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}

初始化完成,我们得到了一个 Segment 数组。如下图所示:

image-20210814031243376

我们就当是用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:

  • Segment 数组长度为 16,不可以扩容
  • Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容
  • 这里初始化了 segment[0],其他位置还是 null,至于为什么要初始化 segment[0],后面的代码会介绍
  • 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,姑且把它们简单翻译为移位数掩码,这两个值马上就会用到

可以看到 ConcurrentHashMap 没有实现懒惰初始化,空间占用不友好

其中 this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment

例如,根据某一 hash 值求 segment 位置,先将高位向低位移动 this.segmentShift 位

image-20210814031359616

结果再与 this.segmentMask 做位于运算,最终得到 1010 即下标为 10 的 segment

image-20210814031416319

3、put过程分析

先看 put 的主流程,对于其中的一些关键细节操作,后面会进行详细介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 1. 计算 key 的 hash 值
int hash = hash(key);
// 2. 根据 hash 值找到 Segment 数组中的位置 j
// hash 是 32 位,无符号右移 segmentShift(28) 位,剩下高 4 位,
// 然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的高 4 位,也就是槽的数组下标
int j = (hash >>> segmentShift) & segmentMask;
// 刚刚说了,初始化的时候初始化了 segment[0],但是其他位置还是 null,
// 获得 segment 对象, 判断是否为 null, 是则创建该 segment
// ensureSegment(j) 对 segment[j] 进行初始化
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
// 这时不能确定是否真的为 null, 因为其它线程也发现该 segment 为 null,
// 因此在 ensureSegment 里用 cas 方式保证该 segment 安全性
s = ensureSegment(j);
// 3. 插入新值到 槽 s 中
// 进入 segment 的put 流程
return s.put(key, hash, value, false);
}

第一层皮很简单,根据 hash 值很快就能找到相应的 Segment,之后就是 Segment 内部的 put 操作了。

Segment 内部是由 数组+链表 组成的。segment 继承了可重入锁(ReentrantLock),它的 put 方法为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 segment 写入前,需要先获取该 segment 的独占锁
// 先看主流程,后面还会具体介绍这部分内容
// 尝试加锁
HashEntry<K,V> node = tryLock() ? null :
// 如果不成功, 进入 scanAndLockForPut 流程
// 如果是多核 cpu 最多 tryLock 64 次, 进入 lock 流程
// 在尝试期间, 还可以顺便看该节点在链表中有没有, 如果没有顺便创建出来
scanAndLockForPut(key, hash, value);
// 执行到这里 segment 已经被成功加锁, 可以安全执行
V oldValue;
try {
// 这个是 segment 内部的数组
HashEntry<K,V>[] tab = table;
// 再利用 hash 值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
// first 是数组该位置处的链表的表头
HashEntry<K,V> first = entryAt(tab, index);

// 下面这串 for 循环虽然很长,不过也很好理解,想想该位置没有任何元素和已经存在一个链表这两种情况
for (HashEntry<K,V> e = first;;) {
if (e != null) {
// 更新
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
// 覆盖旧值
e.value = value;
++modCount;
}
break;
}
// 继续顺着链表走
e = e.next;
}
else {
// 新增
// 1) 之前等待锁时, node 已经被创建, next 指向链表头
// node 到底是不是 null,这个要看获取锁的过程,不过和这里都没有关系。
// 如果不为 null,那就直接将它设置为链表表头;如果是null,初始化并设置为链表表头。
if (node != null)
node.setNext(first);
else
// 2) 创建新 node
node = new HashEntry<K,V>(hash, key, value, first);

int c = count + 1;
// 3) 扩容
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容后面也会具体分析
else
// 没有达到阈值,将 node 放到数组 tab 的 index 位置,
// 其实就是将新的节点设置成原链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
// 解锁
unlock();
}
return oldValue;
}

整体流程还是比较简单的,由于有独占锁的保护,所以 segment 内部的操作并不复杂。至于这里面的并发问题,我们稍后再进行介绍。

到这里 put 操作就结束了,接下来,我们说一说其中几步关键的操作。

4、初始化槽:ensureSegment

ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。

这里需要考虑并发,因为很可能会有多个线程同时进来初始化同一个槽 segment[k],不过只要有一个成功了就可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 这里看到为什么之前要初始化 segment[0] 了,
// 使用当前 segment[0] 处的数组长度和负载因子来初始化 segment[k]
// 为什么要用“当前”,因为 segment[0] 可能早就扩容过了
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);

// 初始化 segment[k] 内部的数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // 再次检查一遍该槽是否被其他线程初始化了。

Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}

总的来说,ensureSegment(int k) 比较简单,对于并发操作使用 CAS 进行控制

5、获取写入锁:scanAndLockForPut

前面我们看到,在往某个 segment 中 put 的时候,首先会调用

1
node = tryLock() ? null : scanAndLockForPut(key, hash, value)

也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。

下面我们来具体分析这个方法中是怎么控制加锁的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node

// 循环获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
// 进到这里说明数组该位置的链表是空的,没有任何元素
// 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
// 顺着链表往下走
e = e.next;
}
// 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
// lock() 是阻塞方法,直到获取锁后返回
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
// 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
// 所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}

这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。

这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该 segment 的独占锁,如果需要的话顺便实例化了一下 node

6、扩容:rehash

重复一下,segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍

由于扩容发生在 put 中,因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全

首先,我们要回顾一下触发扩容的地方,put 的时候,如果判断该值的插入会导致该 segment 的元素个数超过阈值,那么先进行扩容,再插值。

1
2
3
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容后面也会具体分析

该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 2 倍
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
// 创建新数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
int sizeMask = newCapacity - 1;

// 遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
for (int i = 0; i < oldCapacity ; i++) {
// e 是链表的第一个元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算应该放置在新数组中的位置,
// 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) // 该位置处只有一个元素,那比较好办
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// e 是链表表头
HashEntry<K,V> lastRun = e;
// idx 是当前链表的头结点 e 的新位置
int lastIdx = idx;

// 下面这个 for 循环会找到一个 lastRun 节点,这个节点之后的所有元素是将要放到一起的
// 过一遍链表, 尽可能把 rehash 后 idx 不变的节点重用(直接搬移,没有进行头插法)
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将 lastRun 及其之后的所有节点组成的这个链表放到 lastIdx 这个位置
newTable[lastIdx] = lastRun;
// 下面的操作是处理 lastRun 之前的节点,剩余节点需要新建
// 这些节点可能分配在另一个链表中,也可能分配到上面的那个链表中
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 扩容完成, 才加入新的节点
// 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
// 替换为新的 HashEntry table
table = newTable;
}

这里的扩容比之前的 HashMap 要复杂一些,代码难懂一点。上面有两个挨着的 for 循环,第一个 for 有什么用呢?

仔细一看发现,如果没有第一个 for 循环,也是可以工作的,但是,这个 for 循环下来,如果 lastRun 的后面还有比较多的节点,那么这次就是值得的。因为我们只需要克隆 lastRun 前面的节点,后面的一串节点跟着 lastRun 走就是了,不需要做任何操作。

我觉得 Doug Lea 的这个想法也是挺有意思的,不过比较坏的情况就是每次 lastRun 都是链表的最后一个元素或者很靠后的元素,那么这次遍历就有点浪费了。不过 Doug Lea 也说了,根据统计,如果使用默认的阈值,大约只有 1/6 的节点需要克隆。

7、get过程分析

相对于 put 来说,get 就很简单了。

  • 计算 hash 值,找到 segment 数组中的具体位置,或我们前面用的“槽”
  • 槽中也是一个数组,根据 hash 找到数组中具体的位置
  • 到这里是链表了,顺着链表进行查找即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
// 1. hash 值
int h = hash(key);
// u 为 segment 对象在数组中的偏移量
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 2. 根据 hash 找到对应的 segment
// s 即为 segment
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 3. 找到segment 内部数组相应位置的链表,遍历
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
8、size计算流程
  • 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
  • 如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
// 超过重试次数, 需要创建所有 segment 并加锁
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 在这里处理的很巧妙
// 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
// 先判断最后得出来的结果是不是与上次得到的结果一致:
// 一致,跳出循环
// 不一致,将本次得到的结果设置为最后的结果,方面下一次的对比
// 重试次数超过 3,将所有 segment 锁住,重新计算个数返回
// 在上面if (retries++ == RETRIES_BEFORE_LOCK)块内上锁,在下面finally的if块中解锁
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
9、并发问题分析

现在我们已经说完了 put 过程和 get 过程,我们可以看到 get 过程中是没有加锁的,那自然我们就需要去考虑并发问题。

添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题,我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。

  • put 操作的线程安全性:
    • 初始化槽,这个我们之前就说过了,使用了 CAS 来初始化 Segment 中的数组
    • 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 **get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject**。
    • 扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 ==table 使用了 volatile 关键字==。
  • remove 操作的线程安全性:
    • remove 操作我们没有分析源码,所以这里说的读者感兴趣的话还是需要到源码中去求实一下的。
    • get 操作需要遍历链表,但是 remove 操作会”破坏”链表
    • 如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题
    • 如果 remove 先破坏了一个节点,分两种情况考虑。
      1. 如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。
      2. 如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的
4、ConcurrentHashMap - JDK1.8

在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现

1、数据结构

img

结构上和 Java8 的 HashMap 基本上一样,不过它要保证线程安全性,所以在源码上确实要复杂一些。

重要属性和内部类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 默认为 0
// 当初始化时, 为 -1
// 当扩容时, 为 -(1 + 扩容线程数)
// 当初始化或扩容完成后,为 下一次的扩容的阈值大小,也就是容量的3/4
private transient volatile int sizeCtl;

// 整个 ConcurrentHashMap 就是一个 Node[]
// Node里面的属性:键。值、hash码、next
static class Node<K,V> implements Map.Entry<K,V> {}

// hash 表
transient volatile Node<K,V>[] table;

// 扩容时的 新 hash 表
private transient volatile Node<K,V>[] nextTable;

// 扩容时如果某个 bin 迁移完毕, 用 ForwardingNode 作为旧 table bin 的头结点
static final class ForwardingNode<K,V> extends Node<K,V> {}

// 用在 compute 以及 computeIfAbsent 时, 用来占位, 计算完成后替换为普通
Node static final class ReservationNode<K,V> extends Node<K,V> {}

// 作为 treebin 的头节点, 存储 root 和 first
static final class TreeBin<K,V> extends Node<K,V> {}

// 作为 treebin 的节点, 存储 parent, left, right
static final class TreeNode<K,V> extends Node<K,V> {}

相关的重要方法

1
2
3
4
5
6
7
8
// 获取 Node[] 中第 i 个 Node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i)

// cas 修改 Node[] 中第 i 个 Node 的值, c 为旧值, v 为新值
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v)

// 直接修改 Node[] 中第 i 个 Node 的值, v 为新值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v)
2、初始化
1
2
3
4
5
6
7
8
9
10
11
// 这构造函数里,什么都不干
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}

这个初始化方法有点意思,通过提供初始容量,计算了 sizeCtl:sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】。如 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,得到 sizeCtl 为 32。

sizeCtl 这个属性使用的场景很多,不过只要跟着文章的思路来,就不会被它搞晕了。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 有参构造
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
// tableSizeFor 仍然是保证计算的大小是 2^n, 即 16,32,64 ...
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}

可以看到实现了懒惰初始化,在构造方法中仅仅计算了 table 的大小,以后在第一次使用时才会真正创建

3、put过程分析

仔细地一行一行代码看下去:(以下数组简称(table),链表简称(bin))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 得到 hash 值
// 其中 spread 方法会综合高位低位, 具有更好的 hash 性
int hash = spread(key.hashCode());
// 用于记录相应链表的长度
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
// f 是链表头节点
// fh 是链表头结点的 hash
// i 是链表在 table 中的下标
Node<K,V> f; int n, i, fh;
// 要创建 table
// 如果数组"空",进行数组初始化
if (tab == null || (n = tab.length) == 0)
// 初始化数组,后面会详细介绍
// 初始化 table 使用了 cas, 无需 synchronized 创建成功, 进入下一轮循环
tab = initTable();

// 找该 hash 值对应的数组下标,得到第一个节点 f
// 要创建链表头节点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果数组该位置为空,
// 用一次 CAS 操作将这个新值放入其中即可,这个 put 操作差不多就结束了,可以拉到最后面了
// 如果 CAS 失败,那就是有并发操作,进到下一个循环就好了
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// hash 居然可以等于 MOVED,这个需要到后面才能看明白,不过从名字上也能猜到,肯定是因为在扩容
// 帮忙扩容
else if ((fh = f.hash) == MOVED)
// 帮助数据迁移,这个等到看完数据迁移部分的介绍后,再理解这个就很简单了、
// 帮忙之后, 进入下一轮循环
tab = helpTransfer(tab, f);

else { // 到这里就是说,f 是该位置的头结点,而且不为空

V oldVal = null;
// 获取数组该位置的头结点的监视器锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 头结点的 hash 值大于 0,说明是链表
// 用于累加,记录链表的长度
binCount = 1;
// 遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果发现了"相等"的 key,判断是否要进行值覆盖,然后也就可以 break 了
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 到了链表的最末端,将这个新值放到链表的最后面
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) { // 红黑树
Node<K,V> p;
binCount = 2;
// 调用红黑树的插值方法插入新节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}

if (binCount != 0) {
// 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8
if (binCount >= TREEIFY_THRESHOLD)
// 这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,
// 如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
// 具体源码我们就不看了,扩容部分后面说
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 增加 size 计数
addCount(1L, binCount);
return null;
}

addCount()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// check 是之前 binCount 的个数(也就是链表的长度)
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if (
// 已经有了 counterCells, 向 cell 累加
(as = counterCells) != null ||
// 还没有, 向 baseCount 累加
U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)
) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (
// 还没有 counterCells
as == null || (m = as.length - 1) < 0 ||
// 还没有 cell
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
// cell cas 增加计数失败
!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
) {
// 创建累加单元数组和cell, 累加重试
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 获取元素个数
s = sumCount();
}
// 可能需要扩容
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// newtable 已经创建了,帮忙扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 需要扩容,这时 newtable 未创建
else if (U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}

这个增加size计数的方法与LongAdder的原理有点像:都是采用的分段累加的思想

它还有一个功能就是:扩容

4、初始化数组:initTable

这个比较简单,主要就是初始化一个合适大小的数组,然后会设置 sizeCtl。

初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 初始化的"功劳"被其他线程"抢去"了
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// CAS 一下,将 sizeCtl 设置为 -1,代表抢到了锁(表示初始化 table)
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 获得锁, 创建 table, 这时其它线程会在 while() 循环中 yield 直至 table 创建
if ((tab = table) == null || tab.length == 0) {
// DEFAULT_CAPACITY 默认初始容量是 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化数组,长度为 16 或初始化时提供的长度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 将这个数组赋值给 table,table 是 volatile 的
table = tab = nt;
// 如果 n 为 16 的话,那么这里 sc = 12
// 其实就是 0.75 * n
sc = n - (n >>> 2);
}
} finally {
// 设置 sizeCtl 为 sc,我们就当是 12 吧
sizeCtl = sc;
}
break;
}
}
return tab;
}
5、链表转红黑树:treeifyBin

前面我们在 put 源码分析也说过,treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容

我们还是进行源码分析吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// MIN_TREEIFY_CAPACITY 为 64
// 所以,如果数组长度小于 64 的时候,其实也就是 32 或者 16 或者更小的时候,会进行数组扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 后面我们再详细分析这个方法
tryPresize(n << 1);
// b 是头结点
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 加锁
synchronized (b) {

if (tabAt(tab, index) == b) {
// 下面就是遍历链表,建立一颗红黑树
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 将红黑树设置到数组相应位置中
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
6、扩容:tryPresize

如果说 Java8 ConcurrentHashMap 的源码不简单,那么说的就是扩容操作和迁移操作。

这个方法要完完全全看懂还需要看之后的 transfer 方法,读者应该提前知道这点。

这里的扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 首先要说明的是,方法参数 size 传进来的时候就已经翻了倍了(n << 1)
private final void tryPresize(int size) {
// c: size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方。
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;

// 这个 if 分支和之前说的初始化数组的代码基本上是一样的,在这里,我们可以不用管这块代码
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2); // 0.75 * n
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
// 我没看懂 rs 的真正含义是什么,不过也关系不大
int rs = resizeStamp(n);

if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 2. 用 CAS 将 sizeCtl 加 1,然后执行 transfer 方法
// 此时 nextTab 不为 null
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 1. 将 sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2)
// 我是没看懂这个值真正的意义是什么? 不过可以计算出来的是,结果是一个比较大的负数
// 调用 transfer 方法,此时 nextTab 参数为 null
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}

这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。

所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。

7、数据迁移:transfer

下面这个方法有点长,将原来的 tab 数组的元素迁移到新的 nextTab 数组中。

虽然我们之前说的 tryPresize 方法中多次调用 transfer 不涉及多线程,但是这个 transfer 方法可以在其他地方被调用,典型地,我们之前在说 put 方法的时候就说过了,请往上看 put 方法,是不是有个地方调用了 helpTransfer 方法,helpTransfer 方法会调用 transfer 方法的。

此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。

阅读源码之前,先要理解并发操作的机制。原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。

第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,这个读者应该能理解吧。其实就是将一个大的迁移任务分为了一个个任务包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;

// stride 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16
// stride 可以理解为”步长“,有 n 个位置是需要进行迁移的,
// 将这 n 个任务分为多个任务包,每个任务包有 stride 个任务
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range

// 如果 nextTab 为 null,先进行一次初始化
// 前面我们说了,外围会保证第一个发起迁移的线程调用此方法时,参数 nextTab 为 null
// 之后参与迁移的线程调用此方法时,nextTab 不会为 null
if (nextTab == null) {
try {
// 容量翻倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
// nextTable 是 ConcurrentHashMap 中的属性
nextTable = nextTab;
// transferIndex 也是 ConcurrentHashMap 的属性,用于控制迁移的位置
transferIndex = n;
}

int nextn = nextTab.length;

// ForwardingNode 翻译过来就是正在被迁移的 Node
// 这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED
// 后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,
// 就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
// 所以它其实相当于是一个标志。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);


// advance 指的是做完了一个位置的迁移工作,可以准备做下一个位置的了
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab

/*
* 下面这个 for 循环,最难理解的在前面,而要看懂它们,应该先看懂后面的,然后再倒回来看
*
*/

// i 是位置索引,bound 是边界,注意是从后往前
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;

// 下面这个 while 真的是不好理解
// advance 为 true 表示可以进行下一个位置的迁移了
// 简单理解结局: i 指向了 transferIndex,bound 指向了 transferIndex-stride
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;

// 将 transferIndex 值赋给 nextIndex
// 这里 transferIndex 一旦小于等于 0,说明原数组的所有位置都有相应的线程去处理了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// 看括号中的代码,nextBound 是这次迁移任务的边界,注意,是从后往前
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
// 所有的迁移操作已经完成
nextTable = null;
// 将新的 nextTab 赋值给 table 属性,完成迁移
table = nextTab;
// 重新计算 sizeCtl: n 是原数组长度,所以 sizeCtl 得出的值将是新数组长度的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}

// 之前我们说过,sizeCtl 在迁移前会设置为 (rs << RESIZE_STAMP_SHIFT) + 2
// 然后,每有一个线程参与迁移就会将 sizeCtl 加 1,
// 这里使用 CAS 操作对 sizeCtl 进行减 1,代表做完了属于自己的任务
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 任务结束,方法退出
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;

// 到这里,说明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
// 也就是说,所有的迁移任务都做完了,也就会进入到上面的 if(finishing){} 分支了
finishing = advance = true;
i = n; // recheck before commit
}
}
// 如果位置 i 处是空的,没有任何节点,那么放入刚刚初始化的 ForwardingNode ”空节点“
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 该位置处是一个 ForwardingNode,代表该位置已经迁移过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 对数组该位置处的结点加锁,开始处理数组该位置处的迁移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 头结点的 hash 大于 0,说明是链表的 Node 节点
if (fh >= 0) {
// 下面这一块和 Java7 中的 ConcurrentHashMap 迁移是差不多的,
// 需要将链表一分为二,
// 找到原链表中的 lastRun,然后 lastRun 及其之后的节点是一起进行迁移的
// lastRun 之前的节点需要进行克隆,然后分到两个链表中
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 其中的一个链表放在新数组的位置 i
setTabAt(nextTab, i, ln);
// 另一个链表放在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
else if (f instanceof TreeBin) {
// 红黑树的迁移
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果一分为二后,节点数少于 8,那么将红黑树转换回链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;

// 将 ln 放置在新数组的位置 i
setTabAt(nextTab, i, ln);
// 将 hn 放置在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
}
}
}
}
}

说到底,transfer 这个方法并没有实现所有的迁移任务,每次调用这个方法只实现了 transferIndex 往前 stride 个位置的迁移工作,其他的需要由外围来控制

这个时候,再回去仔细看 tryPresize 方法可能就会更加清晰一些了。

8、get过程分析

get 方法从来都是最简单的,这里也不例外:

  • 计算 hash 值
  • 根据 hash 值找到数组对应位置:(n - 1) & h
  • 根据该位置处结点性质进行相应查找
    • 如果该位置为 null,那么直接返回 null 就可以了
    • 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
    • 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法
    • 如果以上 3 条都不满足,那就是链表,进行遍历比对即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// spread 方法能确保返回结果是正数
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 判断头结点是否就是我们需要的节点
if ((eh = e.hash) == h) { // 如果头结点已经是要查找的 key
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果头结点的 hash 小于 0,说明 正在扩容,或者该位置是红黑树
// hash 为负数表示该 bin 在扩容中或是 treebin, 这时调用 find 方法来查找
else if (eh < 0)
// 参考 ForwardingNode.find(int h, Object k) 和 TreeBin.find(int h, Object k)
return (p = e.find(h, key)) != null ? p.val : null;

// 遍历链表
// 正常遍历链表, 用 equals 比较
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}

简单说一句,此方法的大部分内容都很简单,只有正好碰到扩容的情况,ForwardingNode.find(int h, Object k) 稍微复杂一些,不过在了解了数据迁移的过程后,这个也就不难了,所以限于篇幅这里也不展开说了。

9、size 计算流程

size 计算实际发生在 put,remove 改变集合元素的操作之中

  • 没有竞争发生,向 baseCount 累加计数
  • 有竞争发生,新建 counterCells,向其中的一个 cell 累加计数
    • counterCells 初始有两个 cell
    • 如果计数竞争比较激烈,会创建新的 cell 来累加计数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}

final long sumCount() {
CounterCell[] as = counterCells;
CounterCell a;
// 将 baseCount 计数与所有 cell 计数累加
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
10、总结

Java 8 数组(Node) +( 链表 Node | 红黑树 TreeNode ) 以下数组简称(table),链表简称(bin)

  • 初始化,使用 cas 来保证并发安全,懒惰初始化 table
  • 树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 bin.length > 8 时,会将链表树化,树化过程会用 synchronized 锁住链表头
  • put,如果该 bin 尚未创建,只需要使用 cas 创建 bin;如果已经有了,锁住链表头进行后续 put 操作,元素添加至 bin 的尾部
  • get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode 它会让 get 操作在新 table 进行搜索
  • 扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时妙的是其它竞争线程也不是无事可做,它们会帮助把其它 bin 进行扩容,扩容时平均只有 1/6 的节点会把复制到新 table 中
  • size,元素个数保存在 baseCount 中,并发时的个数变动保存在 CounterCell[] 当中。最后统计数量时累加即可
5、对比总结
  • HashTable:使用了synchronized关键字对put等操作进行加锁;
  • ConcurrentHashMap JDK1.7:使用分段锁机制实现;
  • ConcurrentHashMap JDK1.8:则使用数组+链表+红黑树数据结构和CAS原子操作实现;
6、正确使用ConcurrentHashMap——computeIfAbsent()方法

示例:单词计数

生成测试数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static final String ALPHA = "abcedfghijklmnopqrstuvwxyz";

public static void main(String[] args) {
int length = ALPHA.length();
int count = 200;
List<String> list = new ArrayList<>(length * count);
for (int i = 0; i < length; i++) {
char ch = ALPHA.charAt(i);
for (int j = 0; j < count; j++) {
list.add(String.valueOf(ch));
}
}
Collections.shuffle(list);
for (int i = 0; i < 26; i++) {
try (PrintWriter out = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream("tmp/" + (i+1) + ".txt")))) {
String collect = list.subList(i * count, (i + 1) * count).stream()
.collect(Collectors.joining("\n"));
out.print(collect);
} catch (IOException e) {
}
}
}

模版代码,模版代码中封装了多线程读取文件的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private static <V> void demo(Supplier<Map<String,V>> supplier, BiConsumer<Map<String,V>,List<String>> consumer) {
Map<String, V> counterMap = supplier.get();
List<Thread> ts = new ArrayList<>();
for (int i = 1; i <= 26; i++) {
int idx = i;
Thread thread = new Thread(() -> {
List<String> words = readFromFile(idx);
consumer.accept(counterMap, words);
});
ts.add(thread);
}
ts.forEach(t->t.start());
ts.forEach(t-> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(counterMap);
}

public static List<String> readFromFile(int i) {
ArrayList<String> words = new ArrayList<>();
try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream("tmp/" + i +".txt")))) {
while(true) {
String word = in.readLine(); if(word == null) {
break;
}
words.add(word);
}
return words;
} catch (IOException e) {
throw new RuntimeException(e);
}
}

你要做的是实现两个参数

  • 一是提供一个 map 集合,用来存放每个单词的计数结果,key 为单词,value 为计数
  • 二是提供一组操作,保证计数的安全性,会传递 map 集合以及 单词 List

正确结果输出应该是每个单词出现 200 次

1
{a=200, b=200, c=200, d=200, e=200, f=200, g=200, h=200, i=200, j=200, k=200, l=200, m=200, n=200, o=200, p=200, q=200, r=200, s=200, t=200, u=200, v=200, w=200, x=200, y=200, z=200} 

下面的实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
demo(
// 创建 map 集合
// 创建 ConcurrentHashMap 对不对?
() -> new HashMap<String, Integer>(),
// 进行计数
(map, words) -> {
for (String word : words) {
Integer counter = map.get(word);
int newValue = counter == null ? 1 : counter + 1;
map.put(word, newValue);
}
}
);

有没有问题?请改进

问题:使用HashMap,线程不安全

改进:使用ConcurrentHashMap替换HashMap

运行发现问题:就算加上了ConcurrentHashMap也不能保证线程安全

原因:原因不难发现,ConcurrentHashMap只是保证了单一操作的线程安全,但是单一线程的组合并不保证线程安全。我们可以发现:

  • Integer counter = map.get(word);:根据Key(word单词)获取Value(计数)——读操作
  • int newValue = counter == null ? 1 : counter + 1; map.put(word, newValue);
    • 如果Key(单词)存在,则计数加1
    • 如果Key(单词)不存在,则计数为1
    • 在将结果的Key与Value放入Map容器当中——写操作

虽然ConcurrentHashMap能保证单一的读操作或单一读操作的线程安全,但是读操作与写操作的组合并不能保证线程安全

  • 解决方法1:将读操作与写操作一起加入Synchronized(map)同步代码块当中

    • 缺点:锁的粒度太大,线程的效率降低
  • 解决方法2:使用ConcurrentHashMap的computeIfAbsent()方法

    • 注意:

      • 累加操作也是需要保证线程安全性,所以使用的是LongAdder累加器来完成累加操作
      • 注意不能使用 putIfAbsent,此方法返回的是上一次的 value,首次调用返回 null
    • demo(
          // 创建 map 集合
          // 创建 ConcurrentHashMap 对不对?
          () -> new ConcurrentHashMap<String, LongAdder>(8,0.75f,8),
      
          (map, words) -> {
              for (String word : words) {
      
                  // 如果缺少一个 key,则计算生成一个 value , 然后将  key value 放入 map
                  //                  a      0
                  LongAdder value = map.computeIfAbsent(word, (key) -> new LongAdder());
                  // 执行累加
                  value.increment(); // 2
      
                  /*// 检查 key 有没有
                              Integer counter = map.get(word);
                              int newValue = counter == null ? 1 : counter + 1;
                              // 没有 则 put
                              map.put(word, newValue);*/
              }
          }
      );
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 解决方法3:使用函数式编程,无需原子变量——使用ConcurrentHashMap的merge方法

      - ```java
      demo(
      () -> new ConcurrentHashMap<String, Integer>(),
      (map, words) -> {
      for (String word : words) {
      // 函数式编程,无需原子变量
      map.merge(word, 1, Integer::sum);
      }
      }
      );
7、JDK 7 HashMap 并发死链问题原理
1、JDK 7 HashMap 并发死链问题

JDK 7 HashMap会出现死链问题的原因:JDK 7 HashMap的扩容数组的方法

  • JDK 7 HashMap使用的是头插法进行扩容数组的(JDK 8 HashMap使用的是尾插法——“七上八下”)
  • 在多线程下,就有可能出现死链问题
    • 实际上就是一个线程在扩容时把链表节点倒过来了,而另一个线程在扩容时正好也在前一个节点,就死循环了

下面使用一些测试代码和用debug的模式来验证JDK 7 HashMap 并发死链问题

注意:

  • 要在 JDK 7 下运行,否则扩容机制和 hash 的计算方法都变了
  • 以下测试代码是精心准备的,不要随便改动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.HashMap;

public class TestDeadLink {
public static void main(String[] args) {
// 测试 java 7 中哪些数字的 hash 结果相等
System.out.println("长度为16时,桶下标为1的key");
for (int i = 0; i < 64; i++) {
if (hash(i) % 16 == 1) {
System.out.println(i);
}
}
System.out.println("长度为32时,桶下标为1的key");
for (int i = 0; i < 64; i++) {
if (hash(i) % 32 == 1) {
System.out.println(i);
}
}
// 1, 35, 16, 50 当大小为16时,它们在一个桶内
final HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
// 放 12 个元素
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
map.put(6, null);
map.put(7, null);
map.put(8, null);
map.put(9, null);
map.put(10, null);
map.put(16, null);
map.put(35, null);
map.put(1, null);

System.out.println("扩容前大小[main]:"+map.size());
new Thread() {
@Override
public void run() {
// 放第 13 个元素, 发生扩容
map.put(50, null);
System.out.println("扩容后大小[Thread-0]:"+map.size());
}
}.start();
new Thread() {
@Override
public void run() {
// 放第 13 个元素, 发生扩容
map.put(50, null);
System.out.println("扩容后大小[Thread-1]:"+map.size());
}
}.start();
}

final static int hash(Object k) {
int h = 0;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
}
2、死链复现

调试工具使用 idea

在 HashMap 源码 590 行加断点

1
int newCapacity = newTable.length;

断点的条件如下,目的是让 HashMap 在扩容为 32 时,并且线程为 Thread-0 或 Thread-1 时停下来

1
2
3
4
5
newTable.length==32 && 
(
Thread.currentThread().getName().equals("Thread-0")||
Thread.currentThread().getName().equals("Thread-1")
)

断点暂停方式选择 Thread,否则在调试 Thread-0 时,Thread-1 无法恢复运行运行代码,程序在预料的断点位置停了下来,输出

1
2
3
4
5
6
7
8
9
长度为16时,桶下标为1的key 
1
16
35
50
长度为32时,桶下标为1的key
1
35
扩容前大小[main]:12

接下来进入扩容流程调试

在 HashMap 源码 594 行加断点

1
2
3
Entry<K,V> next = e.next; // 593 
if (rehash) // 594
// ...

这是为了观察 e 节点和 next 节点的状态,Thread-0 单步执行到 594 行,再 594 处再添加一个断点,条件为

1
Thread.currentThread().getName().equals("Thread-0")

这时可以在 Variables 面板观察到 e 和 next 变量,使用 view as -> Object 查看节点状态

1
2
e		(1)->(35)->(16)->null
next (35)->(16)->null

在 Threads 面板选中 Thread-1 恢复运行,可以看到控制台输出新的内容如下,Thread-1 扩容已完成

1
2
newTable[1]  (35)->(1)->null 
扩容后大小:13

这时 Thread-0 还停在 594 处, Variables 面板变量的状态已经变化为

1
2
e		(1)->null
next (35)->(1)->null

为什么呢,因为 Thread-1 扩容时链表也是后加入的元素放入链表头,因此链表就倒过来了,但 Thread-1 虽然结果正确,但它结束后 Thread-0 还要继续运行

接下来就可以单步调试(F8)观察死链的产生了

下一轮循环到 594,将 e 搬迁到 newTable 链表头

1
2
3
newTable[1]		(35)->(1)->null 
e (1)->null
next (1)->null

下一轮循环到 594,将 e 搬迁到 newTable 链表头

1
2
3
newTable[1]		(35)->(1)->null 
e (1)->null
next null

再看看源码

1
2
3
4
5
6
7
e.next = newTable[1];
// 这时 e (1,35)
// 而 newTable[1] (35,1)->(1,35) 因为是同一个对象
newTable[1] = e;
// 再尝试将 e 作为链表头, 死链已成
e = next;
// 虽然 next 是 null, 会进入下一个链表的复制, 但死链已经形成了
3、通过JDK 7 HashMap源码分析死链问题

HashMap 的并发死链发生在扩容时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 将 table 迁移至 newTable
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
// 1 处
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 2 处
// 将新元素加入 newTable[i], 原 newTable[i] 作为新元素的
next e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}

假设 map 中初始元素是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
原始链表,格式:[下标] (key,next)
[1] (1,35)->(35,16)->(16,null)
线程 a 执行到 1 处 ,此时局部变量 e 为 (1,35),而局部变量 next 为 (35,16) 线程 a 挂起
线程 b 开始执行
第一次循环
[1] (1,null)
第二次循环
[1] (35,1)->(1,null)
第三次循环
[1] (35,1)->(1,null)
[17] (16,null)
切换回线程 a,此时局部变量 e 和 next 被恢复,引用没变但内容变了:e 的内容被改为 (1,null),而 next 的内容被改为 (35,1) 并链向 (1,null)
第一次循环
[1] (1,null)
第二次循环,注意这时 e 是 (35,1) 并链向 (1,null) 所以 next 又是 (1,null)
[1] (35,1)->(1,null)
第三次循环,e 是 (1,null),而 next 是 null,但 e 被放入链表头,这样 e.next 变成了 35 (2 处)
[1] (1,35)->(35,1)->(1,35)
已经是死链了
4、小结
  • 究其原因,是因为在多线程环境下使用了非线程安全的 map 集合
  • JDK 8 虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序)(尾插法),但仍不意味着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)

5、HashSet不安全

同理,HashSet在多线程的环境下也是不安全的。解决方法:JUC的CopyOnWriteArraySet

CopyOnWriteArraySet:对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList

10、JUC并发集合:BlockingQueue接口(阻塞队列)

JUC里的 BlockingQueue 接口表示一个线程安全放入和提取实例的队列。下面将给你演示如何使用这个 BlockingQueue,不会讨论如何在 Java 中实现一个你自己的 BlockingQueue。

1、BAT大厂的面试问题

  • 什么是BlockingDeque?
  • BlockingQueue大家族有哪些?
    • ArrayBlockingQueue, DelayQueue, LinkedBlockingQueue, SynchronousQueue…
  • BlockingQueue适合用在什么样的场景?
  • BlockingQueue常用的方法?
  • BlockingQueue插入方法有哪些?这些方法(add(o),offer(o),put(o),offer(o, timeout, timeunit))的区别是什么?
  • BlockingDeque 与BlockingQueue有何关系,请对比下它们的方法?
  • BlockingDeque适合用在什么样的场景?
  • BlockingDeque大家族有哪些?
  • BlockingDeque 与BlockingQueue实现例子?

2、BlockingQueue和BlockingDeque

1、BlockingQueue
1、什么是BlockQueue?

BlockingQueue 通常用于==一个线程生产对象,而另外一个线程消费这些对象==的场景。下图是对这个原理的阐述:

img

线程1往阻塞队列里添加元素,线程2从阻塞队列里移除元素。

  • 一个线程将会持续生产新对象并将其插入到队列之中,直到队列达到它所能容纳的临界点。也就是说,它是有限的
  • 如果该阻塞队列到达了其临界点,负责生产的线程将会在往里边插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象。
  • 负责消费的线程将会一直从该阻塞队列中拿出对象。如果消费线程尝试去从一个空的队列中提取对象的话,这个消费线程将会处于阻塞之中,直到一个生产线程把一个对象丢进队列。
2、为什么需要BlockQueue?
  • 好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue 都给你一手包办了
  • 在 concurrent 包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。
3、适用场景——经典的“生产者”和 “消费者”模型

在多线程环境中,通过队列可以很容易实现数据共享,比如经典的“生产者”和 “消费者”模型中,通过队列可以很便利地实现两者之间的数据共享。

假设我们有若干生产者线程,另外又有若干个消费者线程。如果生产者线程需要把准备好的数据共享给消费者线程,利用队列的方式来传递数据,就可以很方便地解决他们之间的数据共享问题。

但如果生产者和消费者在某个时间段内,万一发生数据处理速度不匹配的情况呢?理想情况下,如果生产者产出数据的速度大于消费者消费的速度,并且当生产出来的数据累积到一定程度的时候,那么生产者必须暂停等待一下(阻塞生产者线程),以便等待消费者线程把累积的数据处理完毕,反之亦然。

  • 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列
  • 队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒
2、BlockingQueue的方法

BlockingQueue 具有 4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:

抛异常 特定值 阻塞 超时
插入 add(o) offer(o) put(o) offer(o, timeout, timeunit)
移除 remove(o) poll(o) take(o) poll(timeout, timeunit)
检查 element(o) peek(o)

四组不同的行为方式解释:

  • 抛异常:如果试图的操作无法立即执行,抛一个异常。
  • 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
  • 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
  • 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。

BlockingQueue 的核心方法:

  • 放入数据
    1. offer(anObject):表示如果可能的话,将 anObject 加到 BlockingQueue 里,即如果 BlockingQueue 可以容纳,则返回 true,否则返回 false。(本方法不阻塞当前执行方法的线程
    2. offer(E o, long timeout, TimeUnit unit):可以设定等待的时间,如果在指定的时间内,还不能往队列中加入 BlockingQueue,则返回失败。
    3. put(anObject):把 anObject 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程被阻断直到 BlockingQueue 里面有空间再继续。
    4. add(anObject):把 anObject 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程抛出一个异常:Queue full。
  • 获取数据
    1. poll(time):取走 BlockingQueue 里排在首位的对象,若不能立即取出,则可以等time 参数规定的时间,取不到时返回 null
    2. poll(long timeout, TimeUnit unit):从 BlockingQueue 取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间超时还没有数据可取,返回null。
    3. take():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,阻断进入等待状态直到 BlockingQueue 有新的数据被加入
    4. remove():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,抛出一个异常:NoSuchElementException
    5. drainTo():一次性从 BlockingQueue 获取所有可用的数据对象(还可以指定
      获取数据的个数),通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁。

注意:

  • 无法向一个 BlockingQueue 中插入 null。如果你试图插入 null,BlockingQueue 将会抛出一个 NullPointerException。
  • 可以访问到 BlockingQueue 中的所有元素,而不仅仅是开始和结束的元素。比如说,你将一个对象放入队列之中以等待处理,但你的应用想要将其取消掉。那么你可以调用诸如 remove(o) 方法来将队列之中的特定对象进行移除。
  • 但是这么干效率并不高(译者注:基于队列的数据结构,获取除开始或结束位置的其他对象的效率不会太高),因此你尽量不要用这一类的方法,除非你确实不得不那么做。
3、BlockingDeque

java.util.concurrent 包里的 BlockingDeque 接口表示一个线程安全放入和提取实例的双端队列。

BlockingDeque 类是一个双端队列,在不能够插入元素时,它将阻塞住试图插入元素的线程;在不能够抽取元素时,它将阻塞住试图抽取的线程。 deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。

使用情景:

  • 在线程既是一个队列的生产者又是这个队列的消费者的时候可以使用到 BlockingDeque
  • 如果生产者线程需要在队列的两端都可以插入数据,消费者线程需要在队列的两端都可以移除数据,这个时候也可以使用 BlockingDeque。

BlockingDeque 图解:

img

4、BlockingDeque的方法

一个 BlockingDeque - 线程在双端队列的两端都可以插入和提取元素。 一个线程生产元素,并把它们插入到队列的任意一端。如果双端队列已满,插入线程将被阻塞,直到一个移除线程从该队列中移出了一个元素。如果双端队列为空,移除线程将被阻塞,直到一个插入线程向该队列插入了一个新元素。

BlockingDeque 具有 4 组不同的方法用于插入、移除以及对双端队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:

抛异常 特定值 阻塞 超时
插入 addFirst(o) offerFirst(o) putFirst(o) offerFirst(o, timeout, timeunit)
移除 removeFirst(o) pollFirst(o) takeFirst(o) pollFirst(timeout, timeunit)
检查 getFirst(o) peekFirst(o)
抛异常 特定值 阻塞 超时
插入 addLast(o) offerLast(o) putLast(o) offerLast(o, timeout, timeunit)
移除 removeLast(o) pollLast(o) takeLast(o) pollLast(timeout, timeunit)
检查 getLast(o) peekLast(o)

四组不同的行为方式解释:

  • 抛异常:如果试图的操作无法立即执行,抛一个异常。
  • 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
  • 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
  • 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。
5、BlockingQueue和BlockingDeque的关系

BlockingDeque 接口继承自 BlockingQueue 接口。这就意味着你可以像使用一个 BlockingQueue 那样使用 BlockingDeque。如果你这么干的话,各种插入方法将会把新元素添加到双端队列的尾端,而移除方法将会把双端队列的首端的元素移除。正如 BlockingQueue 接口的插入和移除方法一样。

以下是 BlockingDeque 对 BlockingQueue 接口的方法的具体内部实现:

BlockingQueue BlockingDeque
add() addLast()
offer() x 2 offerLast() x 2
put() putLast()
remove() removeFirst()
poll() x 2 pollFirst()
take() takeFirst()
element() getFirst()
peek() peekFirst()

3、BlockingQueue的例子

这里是一个 Java 中使用 BlockingQueue 的示例。本示例使用的是 BlockingQueue 接口的 ArrayBlockingQueue 实现。 首先,BlockingQueueExample 类分别在两个独立的线程中启动了一个 Producer 和 一个 Consumer。Producer 向一个共享的 BlockingQueue 中注入字符串,而 Consumer 则会从中把它们拿出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class BlockingQueueExample {

public static void main(String[] args) throws Exception {

BlockingQueue queue = new ArrayBlockingQueue(1024);

Producer producer = new Producer(queue);
Consumer consumer = new Consumer(queue);

new Thread(producer).start();
new Thread(consumer).start();

Thread.sleep(4000);
}
}

以下是 Producer 类。注意它在每次 put() 调用时是为何休眠一秒钟的。这将导致 Consumer 在等待队列中对象的时候发生阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Producer implements Runnable{

protected BlockingQueue queue = null;

public Producer(BlockingQueue queue) {
this.queue = queue;
}

public void run() {
try {
queue.put("1");
Thread.sleep(1000);
queue.put("2");
Thread.sleep(1000);
queue.put("3");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

以下是 Consumer 类。它只是把对象从队列中抽取出来,然后将它们打印到 System.out。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Consumer implements Runnable{

protected BlockingQueue queue = null;

public Consumer(BlockingQueue queue) {
this.queue = queue;
}

public void run() {
try {
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
**1、数组阻塞队列——ArrayBlockingQueue**(常用)

ArrayBlockingQueue 类实现了 BlockingQueue 接口。

ArrayBlockingQueue 是一个有界的阻塞队列,其内部实现是将对象放到一个数组里

  • 有界也就意味着,它不能够存储无限多数量的元素。它有一个同一时间能够存储元素数量的上限。
  • 你可以在对其初始化的时候设定这个上限,但之后就无法对这个上限进行修改了(译者注: 因为它是基于数组实现的,也就具有数组的特性:一旦初始化,大小就无法修改)。
  • ArrayBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。
  • 除了一个定长数组外,ArrayBlockingQueue 内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置

ArrayBlockingQueue 与 LinkedBlockingQueue 的区别:

  • ArrayBlockingQueue 在生产者放入数据和消费者获取数据,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于LinkedBlockingQueue;

    • 按照实现原理来分析,ArrayBlockingQueue 完全可以采用分离锁,从而实现生产者和消费者操作的完全并行运行。Doug Lea 之所以没这样去做,也许是因为 ArrayBlockingQueue 的数据写入和获取操作已经足够轻巧,以至于引入独立的锁机制,除了给代码带来额外的复杂性外,其在性能上完全占不到任何便宜。
  • ArrayBlockingQueue 和LinkedBlockingQueue 间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的Node 对象

    • 这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC 的影响还是存在一定的区别。
  • 在创建 ArrayBlockingQueue 时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁

    以下是在使用 ArrayBlockingQueue 的时候对其初始化的一个示例:

1
2
3
BlockingQueue queue = new ArrayBlockingQueue(1024);
queue.put("1");
Object object = queue.take();

以下是使用了 Java 泛型的一个 BlockingQueue 示例。注意其中是如何对 String 元素放入和提取的:

1
2
3
BlockingQueue<String> queue = new ArrayBlockingQueue<String>(1024);
queue.put("1");
String string = queue.take();

==一句话总结:ArrayBlockingQueue 是由数组结构组成的有界阻塞队列。==

2、延迟队列——DelayQueue

DelayQueue 实现了 BlockingQueue 接口。

DelayQueue 是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。

DelayQueue 对元素进行持有直到一个特定的延迟到期。注入其中的元素必须实现 java.util.concurrent.Delayed 接口,该接口定义:

1
2
3
public interface Delayed extends Comparable<Delayed> {
public long getDelay(TimeUnit timeUnit);
}

DelayQueue 将会在每个元素的 getDelay() 方法返回的值的时间段之后才释放掉该元素。如果返回的是 0 或者负值,延迟将被认为过期,该元素将会在 DelayQueue 的下一次 take 被调用的时候被释放掉

传递给 getDelay 方法的 getDelay 实例是一个枚举类型,它表明了将要延迟的时间段。TimeUnit 枚举将会取以下值:

  • DAYS——天
  • HOURS——时
  • INUTES——分钟
  • SECONDS——秒
  • MILLISECONDS——毫秒
  • MICROSECONDS——微秒
  • NANOSECONDS——纳秒

正如你所看到的,Delayed 接口也继承了 java.lang.Comparable 接口,这也就意味着 Delayed 对象之间可以进行对比。这个可能在对 DelayQueue 队列中的元素进行排序时有用,因此它们可以根据过期时间进行有序释放。 以下是使用 DelayQueue 的例子:

1
2
3
4
5
6
7
8
public class DelayQueueExample {
public static void main(String[] args) {
DelayQueue queue = new DelayQueue();
Delayed element1 = new DelayedElement();
queue.put(element1);
Delayed element2 = queue.take();
}
}

DelayedElement 是我所创建的一个 DelayedElement 接口的实现类,它不在 java.util.concurrent 包里。你需要自行创建你自己的 Delayed 接口的实现以使用 DelayQueue 类。

==一句话总结:使用优先级队列实现的延迟无界阻塞队列。==

**3、链阻塞队列——LinkedBlockingQueue**(常用)

LinkedBlockingQueue 类实现了 BlockingQueue 接口。

LinkedBlockingQueue 内部以一个链式结构(链接节点)对其元素进行存储。如果需要的话,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限。

LinkedBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。

LinkedBlockingQueue 之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

ArrayBlockingQueue 和 LinkedBlockingQueue 是两个最普通也是最常用的阻塞队列,一般情况下,在处理多线程间的生产者消费者问题,使用这两个类足以。

以下是 LinkedBlockingQueue 的初始化和使用示例代码:

1
2
3
4
BlockingQueue<String> unbounded = new LinkedBlockingQueue<String>();
BlockingQueue<String> bounded = new LinkedBlockingQueue<String>(1024);
bounded.put("Value");
String value = bounded.take();

==一句话总结:由链表结构组成的有界(但大小默认值为integer.MAX_VALUE)阻塞队列。==

4、具有优先级的阻塞队列——PriorityBlockingQueue

PriorityBlockingQueue 类实现了 BlockingQueue 接口。

PriorityBlockingQueue 是一个基于优先级的无界的并发队列。(优先级的判断通过构造函数传入的 Compator 对象来决定)

它使用了和类 java.util.PriorityQueue 一样的排序规则。

  • 你无法向这个队列中插入 null 值。
  • 所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口。因此该队列中元素的排序就取决于你自己的 Comparable 实现。

注意:

  • PriorityBlockingQueue 对于具有相等优先级(compare() == 0)的元素并不强制任何特定行为。
  • 如果你从一个 PriorityBlockingQueue 获得一个 Iterator 的话,该 Iterator 并不能保证它对元素的遍历是以优先级为序的。
  • 由于PriorityBlockingQueue是无界的,所以PriorityBlockingQueue 并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者
    • 因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。
  • 在实现 PriorityBlockingQueue 时,内部控制线程同步的锁采用的是==公平锁==。

以下是使用 PriorityBlockingQueue 的示例:

1
2
3
4
BlockingQueue queue   = new PriorityBlockingQueue();
//String implements java.lang.Comparable
queue.put("Value");
String value = queue.take();

==一句话总结:支持优先级排序的无界阻塞队列。==

5、同步队列——SynchronousQueue

SynchronousQueue 类实现了 BlockingQueue 接口。

SynchronousQueue 是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。 据此,把这个类称作一个队列显然是夸大其词了。它更多像是一个汇合点。

声明一个 SynchronousQueue 有两种不同的方式——公平模式非公平模式,它们之间有着不太一样的行为:

  • 公平模式:SynchronousQueue 会采用公平锁,并配合一个 FIFO 队列来阻塞多余的生产者和消费者,从而体系整体的公平策略;
  • 非公平模式(SynchronousQueue 默认):SynchronousQueue 采用非公平锁,同时配合一个 LIFO 队列来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。

==一句话总结:不存储元素的阻塞队列,也即单个元素的队列。==

6、链阻塞无界队列——LinkedTransferQueue

LinkedTransferQueue 是一个由链表结构组成的无界阻塞 TransferQueue 队列。相对于其他阻塞队列,LinkedTransferQueue 多了 tryTransfer 和transfer 方法。

LinkedTransferQueue 采用一种预占模式。意思就是消费者线程取元素时,如果队列不为空,则直接取走数据,若队列为空,那就生成一个节点(节点元素为 null)入队,然后消费者线程被等待在这个节点上,后面生产者线程入队时发现有一个元素为 null 的节点,生产者线程就不入队了,直接就将元素填充到该节点,并唤醒该节点等待的线程,被唤醒的消费者线程取走元素,从调用的方法返回。

==一句话总结:由链表组成的无界阻塞队列。==

4、BlockingDeque 的例子

既然 BlockingDeque 是一个接口,那么你想要使用它的话就得使用它的众多的实现类的其中一个。java.util.concurrent 包提供了以下 BlockingDeque 接口的实现类:LinkedBlockingDeque

以下是如何使用 BlockingDeque 方法的一个简短代码示例:

1
2
3
4
5
6
BlockingDeque<String> deque = new LinkedBlockingDeque<String>();
deque.addFirst("1");
deque.addLast("2");

String two = deque.takeLast();
String one = deque.takeFirst();
链阻塞双端队列——LinkedBlockDeque

LinkedBlockingDeque 类实现了 BlockingDeque 接口。

LinkedBlockingDeque 是一个由链表结构组成的双向阻塞队列,即可以从队列的两端插入和移除元素。

deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。

LinkedBlockingDeque 是一个双端队列,在它为空的时候,一个试图从中抽取数据的线程将会阻塞,无论该线程是试图从哪一端抽取数据。

对于一些指定的操作,在插入或者获取队列元素时如果队列状态不允许该操作可能会阻塞住,该线程直到队列状态变更为允许操作,这里的阻塞一般有两种情况:

  • 插入元素时:如果当前队列已满将会进入阻塞状态,一直等到队列有空的位置时再讲该元素插入,该操作可以通过设置超时参数,超时后返回 false 表示操作失败,也可以不设置超时参数一直阻塞,中断后抛出 InterruptedException 异常
  • 读取元素时:如果当前队列为空会阻塞住直到队列不为空然后返回元素,同样可以通过设置超时参数

以下是 LinkedBlockingDeque 实例化以及使用的示例:

1
2
3
4
5
6
BlockingDeque<String> deque = new LinkedBlockingDeque<String>();
deque.addFirst("1");
deque.addLast("2");

String two = deque.takeLast();
String one = deque.takeFirst();

==一句话总结:由链表组成的双向阻塞队列==

11、JUC集合:LinkedBlockingQueue详解

1、LinkedBlockingQueue 原理

1、基本的入队出队
1
2
3
4
5
6
7
8
9
10
11
12
13
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
static class Node<E> {
E item;
/**
* 下列三种情况之一
* - 真正的后继节点
* - 自己, 发生在出队时
* - null, 表示是没有后继节点, 是最后了*/
Node<E> next;
Node(E x) { item = x; }
}
}

初始化链表 last = head = new Node<E>(null); Dummy 节点用来占位,item 为 null

image-20210814034851138

当一个节点入队 last = last.next = node;

image-20210814034913187

再来一个节点入队 last = last.next = node;

image-20210814034931731

出队

1
2
3
4
5
6
Node<E> h = head; 
Node<E> first = h.next;
h.next = h; // help GC head = first;
E x = first.item;
first.item = null;
return x;

h = head

image-20210814035037609

first = h.next

image-20210814035055942

h.next = h

image-20210814035113682

head = first

image-20210814035130035

1
2
3
E x = first.item; 
first.item = null;
return x;

image-20210814035147199

2、加锁分析

==高明之处==在于用了两把锁和 dummy 节点

  • 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
  • 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行(锁住的队列的头和尾)
    • 消费者与消费者线程仍然串行
    • 生产者与生产者线程仍然串行

线程安全分析

  • 当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是 head 节点的线程安全。两把锁保证了入队和出队没有竞争
  • 当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
    • 这里就体现了dummy 占位节点的用处了:就算只剩下一个正常的结点,两把锁锁住的依旧是两个对象,没有竞争
  • 当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞
1
2
3
4
// 用于 put(阻塞) offer(非阻塞)
private final ReentrantLock putLock = new ReentrantLock();
// 用户 take(阻塞) poll(非阻塞)
private final ReentrantLock takeLock = new ReentrantLock();

put 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
// count 用来维护元素计数
final AtomicInteger count = this.count; putLock.lockInterruptibly();
try {
// 满了等待
while (count.get() == capacity) {
// 倒过来读就好: 等待 notFull
notFull.await();
}
// 有空位, 入队且计数加一
enqueue(node);
c = count.getAndIncrement();
// 除了自己 put 以外, 队列还有空位, 由自己叫醒其他 put 线程
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
// 如果队列中有一个元素, 叫醒 take 线程
if (c == 0)
// 这里调用的是 notEmpty.signal() 而不是 notEmpty.signalAll() 是为了减少竞争
signalNotEmpty();
}

take 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
// 如果队列中只有一个空位时, 叫醒 put 线程
// 如果有多个线程进行出队, 第一个线程满足 c == capacity, 但后续线程 c < capacity
if (c == capacity)
// 这里调用的是 notFull.signal() 而不是 notFull.signalAll() 是为了减少竞争
signalNotFull();
return x;
}

由 put 唤醒 put 是为了避免信号不足

2、性能比较

主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较

  • Linked 支持有界,Array 强制有界
  • Linked 实现是链表,Array 实现是数组
  • Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
  • Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的
  • Linked 两把锁,Array 一把锁

12、JUC集合:ConcurrentLinkedQueue详解

  • 一个基于链接节点的无界线程安全队列。此队列按照 ==FIFO(先进先出)原==则对元素进行排序。
  • 队列的头部是队列中时间最长的元素。队列的尾部是队列中时间最短的元素
  • 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素
  • 多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。
  • 此队列不允许使用 null 元素

ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是

  • 两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
  • dummy 节点的引入让两把【锁】将来锁住的是不同对象,避免竞争
  • 只是这【锁】使用了 cas 来实现

事实上,ConcurrentLinkedQueue 应用还是非常广泛的

例如之前讲的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,正是采用了ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用

image-20210814043528344

1、BAT大厂的面试问题

  • 要想用线程安全的队列有哪些选择?
    • Vector,Collections.synchronizedList(List<T> list), ConcurrentLinkedQueue等
  • ConcurrentLinkedQueue实现的数据结构?
  • ConcurrentLinkedQueue底层原理?
    • 全程无锁(CAS)
  • ConcurrentLinkedQueue的核心方法有哪些?
    • offer(),poll(),peek(),isEmpty()等队列常用方法
  • 说说ConcurrentLinkedQueue的HOPS(延迟更新的策略)的设计?
  • ConcurrentLinkedQueue适合什么样的使用场景?

2、ConcurrentLinkedQueue数据结构

通过源码分析可知,ConcurrentLinkedQueue的数据结构与LinkedBlockingQueue的数据结构相同,都是使用的链表结构。

ConcurrentLinkedQueue的数据结构如下:

img

说明:ConcurrentLinkedQueue采用的链表结构,并且包含有一个头结点和一个尾结点。

3、ConcurrentLinkedQueue源码分析

1、类的继承关系
1
2
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
implements Queue<E>, java.io.Serializable {}

说明:ConcurrentLinkedQueue继承了抽象类AbstractQueue,AbstractQueue定义了对队列的基本操作;同时实现了Queue接口,Queue定义了对队列的基本操作,同时,还实现了Serializable接口,表示可以被序列化。

2、类的内部类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
private static class Node<E> {
// 元素
volatile E item;
// next域
volatile Node<E> next;

/**
* Constructs a new node. Uses relaxed write because item can
* only be seen after publication via casNext.
*/
// 构造函数
Node(E item) {
// 设置item的值
UNSAFE.putObject(this, itemOffset, item);
}
// 比较并替换item值
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}

void lazySetNext(Node<E> val) {
// 设置next域的值,并不会保证修改对其他线程立即可见
UNSAFE.putOrderedObject(this, nextOffset, val);
}
// 比较并替换next域的值
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}

// Unsafe mechanics
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// item域的偏移量
private static final long itemOffset;
// next域的偏移量
private static final long nextOffset;

static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = Node.class;
itemOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("item"));
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}

说明:Node类表示链表结点,用于存放元素,包含item域和next域,item域表示元素,next域表示下一个结点,**其利用反射机制和CAS机制来更新item域和next域,==保证原子性==**。

3、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
implements Queue<E>, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = 196745693267521676L;
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// head域的偏移量
private static final long headOffset;
// tail域的偏移量
private static final long tailOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = ConcurrentLinkedQueue.class;
headOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("head"));
tailOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("tail"));
} catch (Exception e) {
throw new Error(e);
}
}

// 头结点
private transient volatile Node<E> head;
// 尾结点
private transient volatile Node<E> tail;
}

说明:属性中包含了head域和tail域,表示链表的头结点和尾结点,同时,ConcurrentLinkedQueue也使用了反射机制和CAS机制来更新头结点和尾结点,==保证原子==性

4、 类的构造函数
  • ConcurrentLinkedQueue()型构造函数

    • public ConcurrentLinkedQueue() {
          // 初始化头结点与尾结点
          head = tail = new Node<E>(null);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31

      - 说明:**该构造函数用于创建一个最初为空的 ConcurrentLinkedQueue,头结点与尾结点指向同一个结点,该结点的item域为null,next域也为null**。

      - `ConcurrentLinkedQueue(Collection<? extends E>)`型构造函数

      - ```java
      public ConcurrentLinkedQueue(Collection<? extends E> c) {
      Node<E> h = null, t = null;
      for (E e : c) { // 遍历c集合
      // 保证元素不为空
      checkNotNull(e);
      // 新生一个结点
      Node<E> newNode = new Node<E>(e);
      if (h == null) // 头结点为null
      // 赋值头结点与尾结点
      h = t = newNode;
      else {
      // 直接头结点的next域
      t.lazySetNext(newNode);
      // 重新赋值头结点
      t = newNode;
      }
      }
      if (h == null) // 头结点为null
      // 新生头结点与尾结点
      h = t = new Node<E>(null);
      // 赋值头结点
      head = h;
      // 赋值尾结点
      tail = t;
      }
    • 说明:该构造函数用于创建一个最初包含给定 collection 元素的 ConcurrentLinkedQueue,按照此 collection 迭代器的遍历顺序来添加元素

5、核心函数分析
1、offer函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public boolean offer(E e) {
// 元素不为null
checkNotNull(e);
// 新生一个结点
final Node<E> newNode = new Node<E>(e);

for (Node<E> t = tail, p = t;;) { // 无限循环
// q为p结点的下一个结点
Node<E> q = p.next;
if (q == null) { // q结点为null
// p is last node
if (p.casNext(null, newNode)) { // 比较并进行替换p结点的next域
// Successful CAS is the linearization point
// for e to become an element of this queue,
// and for newNode to become "live".
if (p != t) // p不等于t结点,不一致 // hop two nodes at a time
// 比较并替换尾结点
casTail(t, newNode); // Failure is OK.
// 返回
return true;
}
// Lost CAS race to another thread; re-read next
}
else if (p == q) // p结点等于q结点
// We have fallen off list. If tail is unchanged, it
// will also be off-list, in which case we need to
// jump to head, from which all live nodes are always
// reachable. Else the new tail is a better bet.
// 原来的尾结点与现在的尾结点是否相等,若相等,则p赋值为head,否则,赋值为现在的尾结点
p = (t != (t = tail)) ? t : head;
else
// Check for tail updates after two hops.
// 重新赋值p结点
p = (p != t && t != (t = tail)) ? t : q;
}
}

说明:offer函数用于将指定元素插入此队列的尾部。下面模拟offer函数的操作,队列状态的变化(假设单线程添加元素,连续添加10、20两个元素)。

img

  • 若ConcurrentLinkedQueue的初始状态如上图所示,即队列为空。单线程添加元素,此时,添加元素10,则状态如下所示:
    • img
  • 如上图所示,添加元素10后,tail没有变化,还是指向之前的结点,继续添加元素20,则状态如下所示:
    • img
  • 如上图所示,添加元素20后,tail指向了最新添加的结点。
2、poll函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public E poll() {
restartFromHead:
for (;;) { // 无限循环
for (Node<E> h = head, p = h, q;;) { // 保存头结点
// item项
E item = p.item;

if (item != null && p.casItem(item, null)) { // item不为null并且比较并替换item成功
// Successful CAS is the linearization point
// for item to be removed from this queue.
if (p != h) // p不等于h // hop two nodes at a time
// 更新头结点
updateHead(h, ((q = p.next) != null) ? q : p);
// 返回item
return item;
}
else if ((q = p.next) == null) { // q结点为null
// 更新头结点
updateHead(h, p);
return null;
}
else if (p == q) // p等于q
// 继续循环
continue restartFromHead;
else
// p赋值为q
p = q;
}
}
}

说明:此函数用于获取并移除此队列的头,如果此队列为空,则返回null。下面模拟poll函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,poll两次)。

img

  • 队列初始状态如上图所示,在poll操作后,队列的状态如下图所示:
    • img
  • 如上图可知,poll操作后,head改变了,并且head所指向的结点的item变为了null。再进行一次poll操作,队列的状态如下图所示:
    • img
  • 如上图可知,poll操作后,head结点没有变化,只是指示的结点的item域变成了null。
3、remove函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public boolean remove(Object o) {
// 元素为null,返回
if (o == null) return false;
Node<E> pred = null;
for (Node<E> p = first(); p != null; p = succ(p)) { // 获取第一个存活的结点
// 第一个存活结点的item值
E item = p.item;
if (item != null &&
o.equals(item) &&
p.casItem(item, null)) { // 找到item相等的结点,并且将该结点的item设置为null
// p的后继结点
Node<E> next = succ(p);
if (pred != null && next != null) // pred不为null并且next不为null
// 比较并替换next域
pred.casNext(p, next);
return true;
}
// pred赋值为p
pred = p;
}
return false;
}

说明:**此函数用于从队列中移除指定元素的单个实例(如果存在)**。其中,会调用到first函数和succ函数,first函数的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Node<E> first() {
restartFromHead:
for (;;) { // 无限循环,确保成功
for (Node<E> h = head, p = h, q;;) {
// p结点的item域是否为null
boolean hasItem = (p.item != null);
if (hasItem || (q = p.next) == null) { // item不为null或者next域为null
// 更新头结点
updateHead(h, p);
// 返回结点
return hasItem ? p : null;
}
else if (p == q) // p等于q
// 继续从头结点开始
continue restartFromHead;
else
// p赋值为q
p = q;
}
}
}

说明:first函数用于找到链表中第一个存活的结点。succ函数源码如下:

1
2
3
4
5
6
final Node<E> succ(Node<E> p) {
// p结点的next域
Node<E> next = p.next;
// 如果next域为自身,则返回头结点,否则,返回next
return (p == next) ? head : next;
}

说明:succ用于获取结点的下一个结点。如果结点的next域指向自身,则返回head头结点,否则,返回next结点。

下面模拟remove函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,执行remove(10)、remove(20)操作)。

img

  • 如上图所示,为ConcurrentLinkedQueue的初始状态,remove(10)后的状态如下图所示:
    • img
  • 如上图所示,当执行remove(10)后,head指向了head结点之前指向的结点的下一个结点,并且head结点的item域置为null。继续执行remove(20),状态如下图所示:
    • img
  • 如上图所示,执行remove(20)后,head与tail指向同一个结点,item域为null。
4、size函数
1
2
3
4
5
6
7
8
9
10
11
public int size() {
// 计数
int count = 0;
for (Node<E> p = first(); p != null; p = succ(p)) // 从第一个存活的结点开始往后遍历
if (p.item != null) // 结点的item域不为null
// Collection.size() spec says to max out
if (++count == Integer.MAX_VALUE) // 增加计数,若达到最大值,则跳出循环
break;
// 返回大小
return count;
}

说明:此函数用于返回ConcurrenLinkedQueue的大小,从第一个存活的结点(first)开始,往后遍历链表,当结点的item域不为null时,增加计数,之后返回大小

4、ConcurrentLinkedQueue示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.util.concurrent.ConcurrentLinkedQueue;

class PutThread extends Thread {
private ConcurrentLinkedQueue<Integer> clq;
public PutThread(ConcurrentLinkedQueue<Integer> clq) {
this.clq = clq;
}

@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
System.out.println("add " + i);
clq.add(i);
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

class GetThread extends Thread {
private ConcurrentLinkedQueue<Integer> clq;
public GetThread(ConcurrentLinkedQueue<Integer> clq) {
this.clq = clq;
}

@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
System.out.println("poll " + clq.poll());
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

public class ConcurrentLinkedQueueDemo {
public static void main(String[] args) {
ConcurrentLinkedQueue<Integer> clq = new ConcurrentLinkedQueue<Integer>();
PutThread p1 = new PutThread(clq);
GetThread g1 = new GetThread(clq);

p1.start();
g1.start();

}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
add 0
poll null
add 1
poll 0
add 2
poll 1
add 3
poll 2
add 4
poll 3
add 5
poll 4
poll 5
add 6
add 7
poll 6
poll 7
add 8
add 9
poll 8

说明:GetThread线程不会因为ConcurrentLinkedQueue队列为空而等待,而是直接返回null,所以当实现队列不空时,等待时,则需要用户自己实现等待逻辑

5、再深入理解

1、HOPS(延迟更新的策略)的设计

通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:

  • tail更新触发时机:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。
  • head更新触发时机:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。

并且在更新操作时,源码中会有注释为:hop two nodes at a time。所以这种延迟更新的策略就被叫做HOPS的大概原因是这个(猜的 😃),从上面更新时的状态图可以看出,head和tail的更新是“跳着的”即中间总是间隔了一个。那么这样设计的意图是什么呢?

如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。

2、ConcurrentLinkedQueue适合的场景

ConcurrentLinkedQueue通过无锁来做到了更高的并发量,是个高性能的队列,但是使用场景相对不如阻塞队列常见,毕竟取数据也要不停的去循环,不如阻塞的逻辑好设计,但是在并发量特别大的情况下,是个不错的选择,性能上好很多,而且这个队列的设计也是特别费力,尤其的使用的改良算法和对哨兵的处理。整体的思路都是比较严谨的,这个也是使用了无锁造成的,我们自己使用无锁的条件的话,这个队列是个不错的参考

13、多线程锁

1、公平锁与非公平锁

1、公平锁

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

  • 优点:所有的线程都能得到资源,不会饿死在队列中。
  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
2、非公平锁

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
3、公平锁与非公平锁

公平和非公平都是排序队列的,但是公平的新创建的线程会排到所有的就绪队列之后,非公平的线程会和就绪队列直接竞争资源,也就是插队。

举个去KFC吃饭的例子(来自敖丙dalao的例子)

  • 现在是早餐时间,敖丙想去kfc搞个早餐,发现有很多人了,一过去没多想,就乖乖到队尾排队,这样大家都觉得很公平,先到先得,所以这是公平锁咯。
    • img
  • 那非公平锁就是,敖丙过去买早餐,发现大家都在排队,但是敖丙这个人有点渣的,就是喜欢插队,那他就直接怼到第一位那去,后面的鸡蛋,米豆都不行,我插队也不敢说什么,只能默默忍受了。
    • img
  • 但是偶尔,鸡蛋也会崛起,叫我滚到后面排队,我也是欺软怕硬,默默到后面排队,就插队失败了。
    • img
4、公平锁与非公平锁的实现——ReentrantLock(具体看一看上文的ReentrantLock)

在上文中介绍了ReentrantLock类,以及ReentrantLock类的三个内部类——SyncNonfairSyncFairSync

其中NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。

而NonfairSync实现的就是非公平锁(ReentrantLock的默认实现),FairSync实现的就是公平锁。

公平锁:(FairSync源码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
// 尝试公平获取锁
protected final boolean tryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取状态
int c = getState();
if (c == 0) { // 状态为0
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
// 设置当前线程独占
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
// 下一个状态
int nextc = c + acquires;
if (nextc < 0) // 超过了int的表示范围
throw new Error("Maximum lock count exceeded");
// 设置状态
setState(nextc);
return true;
}
return false;
}

仔细看FairSync的源码就能发现,它加了一个hasQueuedPredecessors的判断,那他判断里面有些什么玩意呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
public final boolean  hasQueuedPredecessors() {
// The correctness of this depends on head being initia Lized
// before tail and on head.next being accurate if the current
// thread is first in queue.
//其实这个赋值顺序也是很有讲究的,倒过来有可能会导致空指针
Node t = tail; // Read fields in reverse initia Lization orde r
Nodeh = head;
Node s;
return h != t && // h != t 时表示队列中有 Node
// (s = h.next) == null 表示队列中还有没有老二
// 或者队列中老二线程不是此线程
((s = h.next) == null | s. thread != Thread. currentThread());
}

代码的大概意思也是判断当前的线程是不是位于同步队列的首位,是就是返回true,否就返回false。

非公平锁:(NonfairSync源码)

1
2
3
4
5
6
7
8
9
// 获得锁
final void lock() {
if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
// 把当前线程设置独占了锁
setExclusiveOwnerThread(Thread.currentThread());
else // 锁已经被占用,或者set失败
// 以独占模式获取对象,忽略中断
acquire(1);
}

从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。

5、公平锁与非公平锁的实现过程

非公平锁:

  • A线程准备进去获取锁,首先判断了一下state状态,发现是0,所以可以CAS成功,并且修改了当前持有锁的线程为自己。
    • img
  • 这个时候B线程也过来了,也是一上来先去判断了一下state状态,发现是1,那就CAS失败了,真晦气,只能乖乖去等待队列,等着唤醒了,先去睡一觉吧。
    • img
  • A持有久了,也有点腻了,准备释放掉锁,给别的仔一个机会,所以改了state状态,抹掉了持有锁线程的痕迹,准备去叫醒B。
    • img
  • 这个时候有个带绿帽子的仔C过来了,发现state怎么是0啊,果断CAS修改为1,还修改了当前持有锁的线程为自己。
  • B线程被A叫醒准备去获取锁,发现state居然是1,CAS就失败了,只能失落的继续回去等待队列,路线还不忘骂A渣男,怎么骗自己,欺骗我的感情。
    • img

以上就是一个非公平锁的线程,这样的情况就有可能像B这样的线程长时间无法得到资源,优点就是可能有的线程减少了等待时间,提高了利用率。

公平锁:

  • 线A现在想要获得锁,先去判断下state,发现也是0,去看了看队列,自己居然是第一位,果断修改了持有线程为自己。
    • img
  • 线程B过来了,去判断一下state,嗯哼?居然是state=1,那cas就失败了呀,所以只能乖乖去排队了。
    • img
  • 线程A暖男来了,持有没多久就释放了,改掉了所有的状态就去唤醒线程B了,这个时候线程C进来了,但是他先判断了下state发现是0,以为有戏,然后去看了看队列,发现前面有人了,作为新时代的良好市民,果断排队去了。
    • img
  • 线程B得到A的召唤,去判断state了,发现值为0,自己也是队列的第一位,那很香呀,可以得到了。
    • img

以上就是一个公平锁的线程,这样的情况就不会出现线程长时间无法得到资源,缺点就是要判断当前的等待队列是否有线程在等待,花费的开销较大,效率不行。

6、深入:公平锁真的公平吗?

公平锁相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
//重入锁的代码
...
}
return false;
}
1
2
3
4
5
6
7
8
public final boolean hasQueuedPredecessors() {
//其实这个赋值顺序也是很有讲究的,倒过来有可能会导致空指针
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
//初始化
if (compareAndSetHead(new Node()))......①
tail = head;........................②
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}

情景:假设当前有三个线程A、B、C,分别取调用公平锁的lock.lock()

  • 假设线程A一马当先,先获取到锁,此时state == 1。然后线程B,也来到了tryAcquire方法
    • 公平锁与非公平锁的区别就是在tryAcquire中会判断是否有先驱节点,也就是方法hasQueuedPredecessors
  • 此时tailheadnull,所以肯定方法hasQueuedPredecessors返回false
  • 线程B回到tryAcquire中执行cas_state方法,由于A还没有释放锁,所以肯定获取不到,最终返回false,需要加入同步队列。在addWaiter中,由于tail == null 直接进入enq方法。
  • ①和②便是重点。
情景1:
  • 当线程B执行到①,此时head有值,但是tail还是为null

  • 此时线程C也执行到hasQueuedPredecessors

  • Node t = null;
    Node h = new Node();
    此时 h != t && ((s = h.next) == null)  为true
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    因此线程`C`不能插队,也要加入等待队列。

    ###### 情景2:

    - 当线程`B`执行到②,此时`head`有值,且`head == tail`

    - 此时线程`C`也执行到`hasQueuedPredecessors`

    - ```java
    Node t = h
    此时 h != t 为false 短路直接返回

因此线程C可以插队,去执行cas_state方法
假设在执行cas方法之前,线程A已经释放了锁,那么线程C就可以插队,先于B抢到锁。

关于公平锁源码中hasQueuedPredecessors()方法中tail和head赋值顺序问题

如果head先于tail赋值

1
2
3
4
5
6
7
8
public final boolean hasQueuedPredecessors() {
Node h = head; //如果此时head还没有初始化,获得的是null,赋值完后失去时间片
Node t = tail; //此时head完成初始化,且tail != null
Node s;
return h != t && // h != t 成立 没有短路
//h == null 因此h.next会产生NPE
((s = h.next) == null || s.thread != Thread.currentThread());
}
总结

ReentrantLock中的公平锁只有在等待队列中存在等待节点(不包括虚节点)的时候,才是真正意义上的公平锁。

2、可重入锁

1、什么是重入锁

通常情况下,锁可以用来控制多线程的访问行为。那对于同一个线程,如果连续两次对同一把锁进行lock,会怎么样了?

对于一般的锁来说,这个线程就会被永远卡死在那边,比如:

1
2
3
4
5
6
void handle() {
lock();
lock(); //和上一个lock()操作同一个锁对象,那么这里就永远等待了
unlock();
unlock();
}

这个特性相当不好用,因为在实际的开发过程中,函数之间的调用关系可能错综复杂,一个不小心就可能在多个不同的函数中,反复调用lock(),这样的话,线程就自己和自己卡死了。

所以,对于希望傻瓜式编程的我们来说,重入锁就是用来解决这个问题的。重入锁使得同一个线程可以对同一把锁,在不释放的前提下,反复加锁,而不会导致线程卡死。因此,如果我们使用的是重入锁,那么上述代码就可以正常工作。你唯一需要保证的,就是unlock()的次数和lock()一样多(否则会造成死锁)。

image-20210723031848803

2、重入锁的实现原理

java当中的重入锁——Lock接口的实现类ReentrantLock。其中最重要的方法——lock()

重入锁内部实现的主要类如下图:

图片

重入锁的核心功能委托给内部类Sync实现,并且根据是否是公平锁有FairSync和NonfairSync两种实现。这是一种典型的策略模式。

实现重入锁的方法很简单,就是基于一个状态变量state。这个变量保存在AbstractQueuedSynchronizer(AQS)对象中

1
private volatile int state;

当这个state==0时,表示锁是空闲的,大于零表示锁已经被占用, 它的数值表示当前线程重复占用这个锁的次数。因此,lock()的最简单的实现是:

1
2
3
4
5
6
7
8
final void lock() {
// compareAndSetState就是对state进行CAS操作,如果修改成功就占用锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
//如果修改不成功,说明别的线程已经使用了这个锁,那么就可能需要等待
acquire(1);
}

下面是acquire() 的实现:

1
2
3
4
5
6
7
8
9
10
 public final void acquire(int arg) {
//tryAcquire() 再次尝试获取锁,
//如果发现锁就是当前线程占用的,则更新state,表示重复占用的次数,
//同时宣布获得锁成功,这正是重入的关键所在
if (!tryAcquire(arg) &&
// 如果获取失败,那么就在这里入队等待
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//如果在等待过程中 被中断了,那么重新把中断标志位设置上
selfInterrupt();
}
3、公平的重入锁与非公平的重入锁

默认情况下,重入锁是不公平的。

那公平锁和非公平锁实现的核心区别在哪里呢?

  • 对于lock()方法代码:

    • //非公平锁 
       final void lock() {
           //上来不管三七二十一,直接抢了再说
           if (compareAndSetState(0, 1))
               setExclusiveOwnerThread(Thread.currentThread());
           else
               //抢不到,就进队列慢慢等着
               acquire(1);
       }
      
       //公平锁
       final void lock() {
           //直接进队列等着
           acquire(1);
       }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52

      - 从上面的代码中也不难看到,非公平锁如果第一次争抢失败,后面的处理和公平锁是一样的,都是进入等待队列慢慢等。

      - 对于tryLock()方法代码:

      - ```java
      //非公平锁
      final boolean nonfairTryAcquire(int acquires) {
      final Thread current = Thread.currentThread();
      int c = getState();
      if (c == 0) {
      //上来不管三七二十一,直接抢了再说
      if (compareAndSetState(0, acquires)) {
      setExclusiveOwnerThread(current);
      return true;
      }
      }
      //如果就是当前线程占用了锁,那么就更新一下state,表示重复占用锁的次数
      //这是“重入”的关键所在
      else if (current == getExclusiveOwnerThread()) {
      //我又来了哦~~~(重入)
      int nextc = c + acquires;
      if (nextc < 0) // overflow
      throw new Error("Maximum lock count exceeded");
      setState(nextc);
      return true;
      }
      return false;
      }


      //公平锁
      protected final boolean tryAcquire(int acquires) {
      final Thread current = Thread.currentThread();
      int c = getState();
      if (c == 0) {
      //先看看有没有别人在等,没有人等我才会去抢,有人在我前面 ,我就不抢啦
      if (!hasQueuedPredecessors() &&
      compareAndSetState(0, acquires)) {
      setExclusiveOwnerThread(current);
      return true;
      }
      }
      else if (current == getExclusiveOwnerThread()) {
      int nextc = c + acquires;
      if (nextc < 0)
      throw new Error("Maximum lock count exceeded");
      setState(nextc);
      return true;
      }
      return false;
      }
4、Condition

Condition可以理解为重入锁的伴生对象。它提供了在重入锁的基础上,进行等待和通知的机制。可以使用 newCondition()方法生成一个Condition对象,如下所示:

1
2
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();

那Condition对象怎么用呢?在JDK内部就有一个很好的例子。让我们来看一下ArrayBlockingQueue吧。

ArrayBlockingQueue是一个队列,你可以把元素塞入队列(enqueue),也可以拿出来take()。但是有一个小小的条件,就是如果队列是空的,那么take()就需要等待,一直等到有元素了,再返回。

那这个功能,怎么实现呢?这就可以使用Condition对象了。

实际在ArrayBlockingQueue中,就维护一个Condition对象:

1
2
lock = new ReentrantLock(true);
notEmpty = lock.newCondition();

这个notEmpty 就是一个Condition对象。它用来通知其他线程,ArrayBlockingQueue是不是空着的。当我们需要拿出一个元素时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
// 如果队列长度为0,那么就在notEmpty condition上等待了,一直等到有元素进来为止
// 注意,await()方法,一定是要先获得condition伴生的那个lock,才可以使用。
notEmpty.await();
//一旦有人通知我队列里有东西了,我就弹出一个返回
return dequeue();
} finally {
lock.unlock();
}
}

当有元素入队时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
//先拿到锁,拿到锁才能操作对应的Condition对象
lock.lock();
try {
if (count == items.length)
return false;
else {
//入队了, 在这个函数里,就会进行notEmpty的通知,通知相关线程,有数据准备好了
enqueue(e);
return true;
}
} finally {
//释放锁了,等着的那个线程,现在可以去弹出一个元素试试了
lock.unlock();
}
}

private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
//元素已经放好了,通知那个等着拿东西的人吧
notEmpty.signal();
}

因此,整个流程如图所示:

图片

5、显示重入锁(lock)与隐式重入锁(Synchronized
  • 显示重入锁(lock):需要手动的上锁与释放锁的重入锁
    • 如上文所说,lock的ReentrantLock就是显示重入锁
  • 隐式重入锁(Synchronized):自动的上锁与释放锁的重入锁
    • Synchronized的上锁与释放锁是由JVM自动控制的
6、重入锁的使用示例

使用重入锁,实现一个简单的计数器。这个计数器可以保证在多线程环境中,统计数据的精确性,请看下面示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Counter {
//重入锁
private final Lock lock = new ReentrantLock();
private int count;
public void incr() {
// 访问count时,需要加锁
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}

public int getCount() {
//读取数据也需要加锁,才能保证数据的可见性
lock.lock();
try {
return count;
}finally {
lock.unlock();
}
}
}
7、可重入锁总结
  • 显示重入锁(lock)与隐式重入锁(Synchronized
  • 对于同一个线程,重入锁允许你反复获得通一把锁,但是,申请和释放锁的次数必须一致。
  • 默认情况下,重入锁是非公平的,公平的重入锁性能差于非公平锁
  • 重入锁的内部实现是基于CAS操作的
  • 重入锁的伴生对象Condition提供了await()和singal()的功能,可以用于线程间消息通信
  • 如果是不可重入锁的话,第一个锁没有解锁就不能操作第二个锁的内容

3、死锁

1、什么是死锁

两个或多个进程在运行过程中,因争夺资源而造成的一种相互等待的现象,当进程处于这种相互等待的状态时,若无外力作用,它们都将无法再向前推进。

image-20210723034133100

2、产生死锁的三大原因
  1. 竞争可消耗资源
  2. 竞争不可抢占资源
    • 系统中的资源可以分为两类:
      • 可剥夺资源:是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU和主存均属于可剥夺性资源;
      • 另一类资源是不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
      • 还有一种资源:临时资源。
        • 包括硬件中断、信号、消息、缓冲区内的消息等
        • 它可以是可剥夺资源,也可以是不可剥夺资源
    • 产生死锁中的竞争资源指的是竞争不可剥夺资源的临时资源
  3. 进程运行推进顺序不当
    • 若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁
    • 当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁
3、产生死锁的四大条件

产生死锁的必要条件:

  1. 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
  2. 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
  4. 循环等待条件:在发生死锁时,必然存在一个进程–资源的环形链。
4、验证是否发生死锁的方法
  • jps
    • 类似linux的ps -ef
  • jstack
    • jvm自带的堆栈跟踪工具
  • JConsole等工具
5、解决死锁的方法

处理死锁的方法可归结为四种:

  • 预防死锁
  • 避免死锁
  • 检测死锁
  • 解除死锁
1、预防死锁

预防死锁:通过破坏产生死锁的四个必要条件中的一个或几个,以避免发生死锁的方法

  • 破坏“请求和条件”:
    • 必须一次性申请其在整个运行过程中所需的全部资源
      • 优点:简单、易行且安全
      • 缺点:
        • 资源被严重浪费,严重地恶化资源的利用率
        • 使进程经常会发生饥饿现象
    • 对上面方法的改进:允许一个进程只获得运行初期所需的资源后,便开始运行。进程运行过程中再逐步释放已分配给自己的、且已用完毕的全部资源,然后再请求新的所需资源。
  • 破坏“不可抢占条件”:
    • 当一个已经保存了某些不可抢占资源的进程,提出新的资源请求而不能满足时,它必须释放已经保持的所有资源,待以后需要时在重新申请。(这个方法代价太大,一般不使用这个方法)
  • 破坏“循环等待条件”:
    • 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反

对于java来说:

  1. 以确定的顺序获得锁
    • 如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。按照上面的例子,两个线程获得锁的时序图如下:
      • img
    • 如果此时把获得锁的时序改成:
      • img
    • 那么死锁就永远不会发生。 针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。
    • 问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“循环等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生。
  2. 超时放弃(Lock接口中的tryLock(long time, TimeUnit unit)使用的就是这个方法)
    • 当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,
    • 然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。 还是按照之前的例子,时序图如下:
      • img
2、避免死锁

预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得较满意的系统性能。

由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全的状态,则将资源分配给进程;否则,进程等待。

其中最具有代表性的避免死锁算法是银行家算法

银行家算法:首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。

安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。

处在安全状态的进程==一定==不会发生死锁问题,不处在安全状态的进程==可能==发生死锁问题

3、检测死锁
  1. 首先为每个进程和每个资源指定一个唯一的号码;
  2. 然后建立资源分配表和进程等待表。
  3. 资源分配图 + 死锁定理
4、解除死锁

当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:

  • 剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;
  • 撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态消除为止;
    • 所谓代价是指优先级、运行代价、进程的重要性和价值等。
6、死锁代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 演示死锁
*/
public class DeadLock {

//创建两个对象
static Object a = new Object();
static Object b = new Object();

public static void main(String[] args) {
new Thread(()->{
synchronized (a) {
System.out.println(Thread.currentThread().getName()+" 持有锁a,试图获取锁b");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println(Thread.currentThread().getName()+" 获取锁b");
}
}
},"A").start();

new Thread(()->{
synchronized (b) {
System.out.println(Thread.currentThread().getName()+" 持有锁b,试图获取锁a");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a) {
System.out.println(Thread.currentThread().getName()+" 获取锁a");
}
}
},"B").start();
}
}

4、活锁

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import lombok.extern.slf4j.Slf4j;

import static cn.itcast.n2.util.Sleeper.sleep;

@Slf4j(topic = "c.TestLiveLock")
public class TestLiveLock {
static volatile int count = 10;
static final Object lock = new Object();

public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
}
}

解决方法:使两个线程互相错开运行

5、饥饿

很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束。

下面我讲一下我遇到的一个线程饥饿的例子,先来看看使用顺序加锁的方式解决之前的死锁问题:

image-20210806020827445

顺序加锁的解决方案:

image-20210806020910048

6、乐观锁(Optimistic Locking)和悲观锁(Pessimistic Lock)

1、悲观锁(Pessimistic Lock)
  • 当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
  • 悲观锁,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)
  • 之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。悲观锁的实现:
    • 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。
    • Java 里面的同步 synchronized 关键字的实现。
  • 悲观锁主要分为共享锁排他锁
    • 共享锁【shared locks】又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
    • 排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。
  • 悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁
  • 说明:
    • 悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

image-20210726192457057

2、乐观锁(Optimistic Locking)
  • 乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量
  • 乐观锁采取了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:
    • CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
    • 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
  • 乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作
  • 说明:
    • 乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。

image-20210726202536158

乐观锁场景:在线文档

我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。

那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢?通常方案如下:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
  • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。

实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

7、表锁和行锁

表锁行锁主要是mysql数据库的悬挂知识,这里简单提一下,知道概念就行。

  • 表锁:当一个线程在给一张表进行数据操作的时候,会将整一张表都锁起来。在它操作完成之前,其他线程不能对这张表的所有数据进行操作。
    • image-20210726205202616
  • 行锁:一个线程在对一张表的某一行数据进行操作的时候,会将那一行数据锁起来,在它操作完成之前,其他线程不能对这一行数据进行操作,但是对原这张表的其他行数据进行操作是允许的。
    • image-20210726205505114

8、读锁与写锁

读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但不能同时存在读写线程,读写互斥,读读共享。

  • 读锁:共享锁。即允许多个线程一起读取某一个资源——“共享读”
    • 读锁存在”死锁”问题:线程1和线程2一起读取一张表,这时候线程1如果想要修改(写)这张表的数据,就需要线程2完成读操作后退出;同理,这个时候如果线程2也想修改(写)这张表的数据,那么也要等待线程1读取完后退出。这个时候就会出现线程1和线程2互相等待的情况——“死锁”
    • 注意:如果读锁只读不写的话,就不存在”死锁”问题。
    • image-20210726210610999
  • 写锁:独占锁。即一次只允许一个线程对某一个资源进行写操作(这个时候不存在读线程,也不存在写线程(除非是它自己))——“单独写”
    • 写锁存在也”死锁”问题:线程1和线程2对一张表的不同行进行写操作。这个时候线程1想要写线程2操作的行,就需要等待线程2写完毕退出;同理,线程2想要写线程1操作的行,就需要等待线程1写完毕退出。这个时候就会出现线程1和线程2互相等待的情况——“死锁”
    • image-20210726210629126

9、自旋锁与自适应自旋锁、偏向锁

4、关键字:synchronized篇里有详细说明。这里不在赘述。

14、JUC线程池——FutureTask(未来任务)

1、BAT大厂的面试问题

  • FutureTask用来解决什么问题的?为什么会出现?
  • FutureTask类结构关系怎么样的?
  • FutureTask的线程安全是由什么保证的?
  • FutureTask结果返回机制?
  • FutureTask内部运行状态的转变?
  • FutureTask通常会怎么用?举例说明。

2、FutureTask简介

  • FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)**和取消任务(cancel)**等。
  • 如果任务尚未完成,获取任务执行结果时将会阻塞
  • **一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)**。
  • FutureTask 常用来封装 CallableRunnable,也可以作为一个任务提交到线程池中执行
  • 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用
  • FutureTask 的线程安全由CAS来保证

3、FutureTask类关系

img

可以看到,FutureTask实现了RunnableFuture接口,则RunnableFuture接口继承了Runnable接口和Future接口,所以FutureTask既能当做一个Runnable直接被Thread执行,也能作为Future用来得到Callable的计算结果

4、FutureTask源码分析

1、Callable接口

Callable是个泛型接口,泛型V就是要call()方法返回的类型。若是不能返回成功,则抛异常。

对比Runnable接口,Runnable不会返回数据也不能抛出异常。

1
2
3
4
5
6
7
8
9
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
2、Future接口

Future接口代表异步计算的结果通过Future接口提供的方法可以查看异步计算是否执行完成,或者等待执行结果并获取执行结果,同时还可以取消执行

Future接口的定义如下:

1
2
3
4
5
6
7
8
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
  • cancel(boolean mayInterruptIfRunning)cancel()方法用来取消异步任务的执行。
    • 如果异步任务已经完成或者已经被取消,或者由于某些原因不能取消,则会返回false。
    • 如果任务还没有被执行,则会返回true并且异步任务不会被执行。
    • 如果任务已经开始执行了但是还没有执行完成,若mayInterruptIfRunning为true,则会立即中断执行任务的线程并返回true,若mayInterruptIfRunning为false,则会返回true且不会中断任务执行线程。
  • isCanceled()判断任务是否被取消,如果任务在结束(正常执行结束或者执行异常结束)前被取消则返回true,否则返回false。
  • isDone()判断任务是否已经完成,如果完成则返回true,否则返回false。
    • 需要注意的是:任务执行过程中发生异常、任务被取消也属于任务已完成,也会返回true
  • get()获取任务执行结果,如果任务还没完成则会阻塞等待直到任务执行完成
    • 如果任务被取消则会抛出CancellationException异常,如果任务执行过程发生异常则会抛出ExecutionException异常,如果阻塞等待过程中被中断则会抛出InterruptedException异常。
  • get(long timeout,Timeunit unit)带超时时间的get()版本,如果阻塞等待过程中超时则会抛出TimeoutException异常
3、核心属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//内部持有的callable任务,运行完毕后置空
private Callable<V> callable;

//从get()中返回的结果或抛出的异常
private Object outcome; // non-volatile, protected by state reads/writes

//运行callable的线程
private volatile Thread runner;

//使用Treiber栈保存等待线程
private volatile WaitNode waiters;

//任务状态
private volatile int state;
private static final int NEW = 0;
private static final int COMPLETING = 1;
private static final int NORMAL = 2;
private static final int EXCEPTIONAL = 3;
private static final int CANCELLED = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED = 6;

其中需要注意的是**state是volatile类型的**,也就是说只要有任何一个线程修改了这个变量,那么其他所有的线程都会知道最新的值。

7种状态具体表示:

  • NEW:表示是个新的任务或者还没被执行完的任务。这是初始状态
  • COMPLETING任务已经执行完成或者执行任务的时候发生异常,但是任务执行结果或者异常原因还没有保存到outcome字段(outcome字段用来保存任务执行结果,如果发生异常,则用来保存异常原因)的时候,状态会从NEW变更到COMPLETING。但是这个状态会时间会比较短,属于中间状态。
  • NORMAL任务已经执行完成并且任务执行结果已经保存到outcome字段,状态会从COMPLETING转换到NORMAL。这是一个最终态。
  • EXCEPTIONAL任务执行发生异常并且异常原因已经保存到outcome字段中后,状态会从COMPLETING转换到EXCEPTIONAL。这是一个最终态。
  • CANCELLED任务还没开始执行或者已经开始执行但是还没有执行完成的时候,用户调用了cancel(false)方法取消任务且不中断任务执行线程,这个时候状态会从NEW转化为CANCELLED状态。这是一个最终态。
  • INTERRUPTING任务还没开始执行或者已经执行但是还没有执行完成的时候,用户调用了cancel(true)方法取消任务并且要中断任务执行线程但是还没有中断任务执行线程之前,状态会从NEW转化为INTERRUPTING。这是一个中间状态。
  • INTERRUPTED:**调用interrupt()中断任务执行线程之后状态会从INTERRUPTING转换到INTERRUPTED。这是一个最终态。 **
    • 有一点需要注意的是,所有值大于COMPLETING的状态都表示任务已经执行完成(任务正常执行完成,任务执行异常或者任务被取消)。

各个状态之间的可能转换关系如下图所示:

img

4、构造函数
  • FutureTask(Callable callable)

    • public FutureTask(Callable<V> callable) {
          if (callable == null)
              throw new NullPointerException();
          this.callable = callable;
          this.state = NEW;       // ensure visibility of callable
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10

      - 这个构造函数会把传入的Callable变量保存在this.callable字段中,该字段定义为`private Callable<V> callable`;**用来保存底层的调用,在被执行完成以后会指向null,接着会初始化state字段为NEW**。

      - FutureTask(Runnable runnable, V result)

      - ```java
      public FutureTask(Runnable runnable, V result) {
      this.callable = Executors.callable(runnable, result);
      this.state = NEW; // ensure visibility of callable
      }
    • 这个构造函数会把传入的Runnable封装成一个Callable对象保存在callable字段中,同时如果任务执行成功的话就会返回传入的result。这种情况下如果不需要返回值的话可以传入一个null。

    • 顺带看下Executors.callable()这个方法,这个方法的功能是把Runnable转换成Callable,代码如下:

      • public static <T> Callable<T> callable(Runnable task, T result) {
            if (task == null)
               throw new NullPointerException();
            return new RunnableAdapter<T>(task, result);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16

        - 可以看到这里采用的是**适配器模式**,调用`RunnableAdapter<T>(task, result)`方法来适配,实现如下:

        - ```java
        static final class RunnableAdapter<T> implements Callable<T> {
        final Runnable task;
        final T result;
        RunnableAdapter(Runnable task, T result) {
        this.task = task;
        this.result = result;
        }
        public T call() {
        task.run();
        return result;
        }
        }
    • 这个适配器很简单,就是简单的实现了Callable接口,在call()实现中调用Runnable.run()方法,然后把传入的result作为任务的结果返回。

在new了一个FutureTask对象之后,接下来就是在另一个线程中执行这个Task,无论是通过直接new一个Thread还是通过线程池,执行的都是run()方法,接下来就看看run()方法的实现。

5、核心方法——run()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void run() {
//新建任务,CAS替换runner为当前线程
if (state != NEW ||
!UNSAFE.compareAndSwapObject(this, runnerOffset,
null, Thread.currentThread()))
return;
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);//设置执行结果
}
} finally {
// runner must be non-null until state is settled to
// prevent concurrent calls to run()
runner = null;
// state must be re-read after nulling runner to prevent
// leaked interrupts
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);//处理中断逻辑
}
}

说明:

  • 运行任务,如果任务状态为NEW状态,则利用CAS修改为当前线程。执行完毕调用set(result)方法设置执行结果。set(result)源码如下:(使用的也是CAS修改state状态为COMPLETING)

    • protected void set(V v) {
          if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
              outcome = v;
              UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
              finishCompletion();//执行完毕,唤醒等待线程
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28

      - 首先利用cas修改state状态为COMPLETING,设置返回结果,然后使用 lazySet(UNSAFE.putOrderedInt)的方式设置state状态为NORMAL。结果设置完毕后,调用finishCompletion()方法唤醒等待线程,源码如下:

      - ```java
      private void finishCompletion() {
      // assert state > COMPLETING;
      for (WaitNode q; (q = waiters) != null;) {
      if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {//移除等待线程
      for (;;) {//自旋遍历等待线程
      Thread t = q.thread;
      if (t != null) {
      q.thread = null;
      LockSupport.unpark(t);//唤醒等待线程
      }
      WaitNode next = q.next;
      if (next == null)
      break;
      q.next = null; // unlink to help gc
      q = next;
      }
      break;
      }
      }
      //任务完成后调用函数,自定义扩展
      done();

      callable = null; // to reduce footprint
      }
  • 回到run方法,如果在 run 期间被中断,此时需要调用handlePossibleCancellationInterrupt方法来处理中断逻辑,确保任何中断(例如cancel(true))只停留在当前run或runAndReset的任务中,源码如下:

    • private void handlePossibleCancellationInterrupt(int s) {
          //在中断者中断线程之前可能会延迟,所以我们只需要让出CPU时间片自旋等待
          if (s == INTERRUPTING)
              while (state == INTERRUPTING)
                  Thread.yield(); // wait out pending interrupt
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      ##### 6、核心方法——get()

      ```java
      //获取执行结果
      public V get() throws InterruptedException, ExecutionException {
      int s = state;
      if (s <= COMPLETING)
      s = awaitDone(false, 0L);
      return report(s);
      }

说明:FutureTask 通过get()方法获取任务执行结果。如果任务处于未完成的状态(state <= COMPLETING),就调用awaitDone方法(后面单独讲解)等待任务完成。任务完成后,通过report方法获取执行结果或抛出执行期间的异常。

report源码如下:

1
2
3
4
5
6
7
8
9
//返回执行结果或抛出异常
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s >= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
7、核心方法——awaitDone(boolean timed, long nanos)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private int awaitDone(boolean timed, long nanos)
throws InterruptedException {
final long deadline = timed ? System.nanoTime() + nanos : 0L;
WaitNode q = null;
boolean queued = false;
for (;;) {//自旋
if (Thread.interrupted()) {//获取并清除中断状态
removeWaiter(q);//移除等待WaitNode
throw new InterruptedException();
}

int s = state;
if (s > COMPLETING) {
if (q != null)
q.thread = null;//置空等待节点的线程
return s;
}
else if (s == COMPLETING) // cannot time out yet
Thread.yield();
else if (q == null)
q = new WaitNode();
else if (!queued)
//CAS修改waiter
queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
q.next = waiters, q);
else if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {
removeWaiter(q);//超时,移除等待节点
return state;
}
LockSupport.parkNanos(this, nanos);//阻塞当前线程
}
else
LockSupport.park(this);//阻塞当前线程
}
}

说明:awaitDone用于等待任务完成,或任务因为中断或超时而终止。返回任务的完成状态。函数执行逻辑如下:

如果线程被中断,首先清除中断状态,调用removeWaiter移除等待节点,然后抛出InterruptedException。

removeWaiter源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void removeWaiter(WaitNode node) {
if (node != null) {
node.thread = null;//首先置空线程
retry:
for (;;) { // restart on removeWaiter race
//依次遍历查找
for (WaitNode pred = null, q = waiters, s; q != null; q = s) {
s = q.next;
if (q.thread != null)
pred = q;
else if (pred != null) {
pred.next = s;
if (pred.thread == null) // check for race
continue retry;
}
else if (!UNSAFE.compareAndSwapObject(this, waitersOffset,q, s)) //cas替换
continue retry;
}
break;
}
}
}
  • 如果当前状态为结束状态(state>COMPLETING),则根据需要置空等待节点的线程,并返回 Future 状态;
  • 如果当前状态为正在完成(COMPLETING),说明此时 Future 还不能做出超时动作,为任务让出CPU执行时间片;
  • 如果state为NEW,先新建一个WaitNode,然后CAS修改当前waiters;
  • 如果等待超时,则调用removeWaiter移除等待节点,返回任务状态;如果设置了超时时间但是尚未超时,则park阻塞当前线程;
  • 其他情况直接阻塞当前线程。
8、核心方法——cancel(boolean mayInterruptIfRunning)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean cancel(boolean mayInterruptIfRunning) {
//如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED
if (!(state == NEW &&
UNSAFE.compareAndSwapInt(this, stateOffset, NEW,
mayInterruptIfRunning ? INTERRUPTING : CANCELLED)))
return false;
try { // in case call to interrupt throws exception
if (mayInterruptIfRunning) {//可以在运行时中断
try {
Thread t = runner;
if (t != null)
t.interrupt();
} finally { // final state
UNSAFE.putOrderedInt(this, stateOffset, INTERRUPTED);
}
}
} finally {
finishCompletion();//移除并唤醒所有等待线程
}
return true;
}

说明:尝试取消任务。如果任务已经完成或已经被取消,此操作会失败。

  • 如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED。
  • 如果当前状态不为NEW,则根据参数mayInterruptIfRunning决定是否在任务运行中也可以中断。中断操作完成后,调用finishCompletion移除并唤醒所有等待线程。

5、FutureTask示例

常用使用方式:

  • 第一种方式:Future + ExecutorService
  • 第二种方式:FutureTask + ExecutorService
  • 第三种方式:FutureTask + Thread
1、Future使用示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FutureDemo {
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
Future future = executorService.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
Long start = System.currentTimeMillis();
while (true) {
Long current = System.currentTimeMillis();
if ((current - start) > 1000) {
return 1;
}
}
}
});

try {
Integer result = (Integer)future.get();
System.out.println(result);
}catch (Exception e){
e.printStackTrace();
}
}
}
2、FutureTask + Thread例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import java.util.concurrent.*;

public class CallDemo {

public static void main(String[] args) throws ExecutionException, InterruptedException {

/**
* 第一种方式:Future + ExecutorService
* Task task = new Task();
* ExecutorService service = Executors.newCachedThreadPool();
* Future<Integer> future = service.submit(task);
* service.shutdown();
*/


/**
* 第二种方式: FutureTask + ExecutorService
* ExecutorService executor = Executors.newCachedThreadPool();
* Task task = new Task();
* FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
* executor.submit(futureTask);
* executor.shutdown();
*/

/**
* 第三种方式:FutureTask + Thread
*/

// 2. 新建FutureTask,需要一个实现了Callable接口的类的实例作为构造函数参数
FutureTask<Integer> futureTask = new FutureTask<Integer>(new Task());
// 3. 新建Thread对象并启动
Thread thread = new Thread(futureTask);
thread.setName("Task thread");
thread.start();

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("Thread [" + Thread.currentThread().getName() + "] is running");

// 4. 调用isDone()判断任务是否结束
if(!futureTask.isDone()) {
System.out.println("Task is not done");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int result = 0;
try {
// 5. 调用get()方法获取任务结果,如果任务没有执行完成则阻塞等待
result = futureTask.get();
} catch (Exception e) {
e.printStackTrace();
}

System.out.println("result is " + result);

}

// 1. 继承Callable接口,实现call()方法,泛型参数为要返回的类型
static class Task implements Callable<Integer> {

@Override
public Integer call() throws Exception {
System.out.println("Thread [" + Thread.currentThread().getName() + "] is running");
int result = 0;
for(int i = 0; i < 100;++i) {
result += i;
}

Thread.sleep(3000);
return result;
}
}
}

15、JUC强大的辅助类

1、CountDownLatch(减少计数)

CountDownLatch底层也是由AQS,用来同步一个或多个任务的常用并发工具类,强制它们等待由其他任务执行的一组操作完成。

1、BAT大厂的面试问题
  • 什么是CountDownLatch?
  • CountDownLatch底层实现原理?
  • CountDownLatch一次可以唤醒几个任务?
    • 多个
  • CountDownLatch有哪些主要方法?
    • await()、countDown()
  • CountDownLatch适用于什么场景?
  • 写道题:实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束?
    • 使用CountDownLatch 代替wait notify 好处。
2、CountDownLatch介绍

从源码可知,其底层是由AQS提供支持,所以其数据结构可以参考AQS的数据结构,而AQS的数据结构核心就是两个虚拟队列:同步队列sync queue条件队列condition queue,不同的条件会有不同的条件队列。

CountDownLatch主要用来进行线程同步协作,等待所有线程完成倒计时。其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一

CountDownLatch典型的用法是:将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每一个任务完成时,都会在这个锁存器上调用countDown,等待问题被解决的任务调用这个锁存器的await,将他们自己拦住,直至锁存器计数结束。

3、CountDownLatch源码分析
1、类的继承关系

CountDownLatch没有显示继承哪个父类或者实现哪个父接口,它底层是AQS是通过内部类Sync来实现的

1
public class CountDownLatch {}
2、类的内部类

CountDownLatch类存在一个内部类Sync,继承自AbstractQueuedSynchronizer,(AQS)其源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static final class Sync extends AbstractQueuedSynchronizer {
// 版本号
private static final long serialVersionUID = 4982264981922014374L;

// 构造器
Sync(int count) {
setState(count);
}

// 返回当前计数
int getCount() {
return getState();
}

// 试图在共享模式下获取对象状态
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}

// 试图设置状态来反映共享模式下的一个释放
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
// 无限循环
for (;;) {
// 获取状态
int c = getState();
if (c == 0) // 没有被线程占有
return false;
// 下一个状态
int nextc = c-1;
if (compareAndSetState(c, nextc)) // 比较并且设置成功
return nextc == 0;
}
}
}

说明:对CountDownLatch方法的调用会转发到对Sync或AQS的方法的调用,所以,AQS对CountDownLatch提供支持。

3、类的属性

CountDownLatch类的内部只有一个Sync类型的属性:

1
2
3
4
public class CountDownLatch {
// 同步队列
private final Sync sync;
}
4、类的构造函数
1
2
3
4
5
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
// 初始化状态数
this.sync = new Sync(count);
}

说明:该构造函数可以构造一个用给定计数初始化的CountDownLatch,并且构造函数内完成了sync的初始化,并设置了状态数

5、核心函数——await函数

此函数将会使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。其源码如下:

1
2
3
4
public void await() throws InterruptedException {
// 转发到sync对象上
sync.acquireSharedInterruptibly(1);
}

说明:由源码可知,对CountDownLatch对象的await的调用会转发为对Sync的acquireSharedInterruptibly(从AQS继承的方法)方法的调用

  • acquireSharedInterruptibly源码如下:

    • public final void acquireSharedInterruptibly(int arg)
              throws InterruptedException {
          if (Thread.interrupted())
              throw new InterruptedException();
          if (tryAcquireShared(arg) < 0)
              doAcquireSharedInterruptibly(arg);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - 说明:从源码中可知,acquireSharedInterruptibly又调用了CountDownLatch的内部类Sync的tryAcquireShared和AQS的doAcquireSharedInterruptibly函数。

      - tryAcquireShared函数的源码如下:

      - ```java
      protected int tryAcquireShared(int acquires) {
      return (getState() == 0) ? 1 : -1;
      }
    • 说明:该函数只是简单的判断AQS的state是否为0,为0则返回1,不为0则返回-1。

  • doAcquireSharedInterruptibly函数的源码如下:

    • private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
          // 添加节点至等待队列
          final Node node = addWaiter(Node.SHARED);
          boolean failed = true;
          try {
              for (;;) { // 无限循环
                  // 获取node的前驱节点
                  final Node p = node.predecessor();
                  if (p == head) { // 前驱节点为头结点
                      // 试图在共享模式下获取对象状态
                      int r = tryAcquireShared(arg);
                      if (r >= 0) { // 获取成功
                          // 设置头结点并进行繁殖
                          setHeadAndPropagate(node, r);
                          // 设置节点next域
                          p.next = null; // help GC
                          failed = false;
                          return;
                      }
                  }
                  if (shouldParkAfterFailedAcquire(p, node) &&
                      parkAndCheckInterrupt()) // 在获取失败后是否需要禁止线程并且进行中断检查
                      // 抛出异常
                      throw new InterruptedException();
              }
          } finally {
              if (failed)
                  cancelAcquire(node);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41

      - 说明:在AQS的doAcquireSharedInterruptibly中可能会再次调用CountDownLatch的内部类Sync的tryAcquireShared方法和AQS的setHeadAndPropagate方法。

      - setHeadAndPropagate方法源码如下:

      - ```java
      private void setHeadAndPropagate(Node node, int propagate) {
      // 获取头结点
      Node h = head; // Record old head for check below
      // 设置头结点
      // 设置自己为 head
      setHead(node);
      /*
      * Try to signal next queued node if:
      * Propagation was indicated by caller,
      * or was recorded (as h.waitStatus either before
      * or after setHead) by a previous operation
      * (note: this uses sign-check of waitStatus because
      * PROPAGATE status may transition to SIGNAL.)
      * and
      * The next node is waiting in shared mode,
      * or we don't know, because it appears null
      *
      * The conservatism in both of these checks may cause
      * unnecessary wake-ups, but only when there are multiple
      * racing acquires/releases, so most need signals now or soon
      * anyway.
      */
      // propagate 表示有共享资源(例如共享读锁或信号量)
      // 原 head waitStatus == Node.SIGNAL 或 Node.PROPAGATE
      // 现在 head waitStatus == Node.SIGNAL 或 Node.PROPAGATE
      if (propagate > 0 || h == null || h.waitStatus < 0 ||
      (h = head) == null || h.waitStatus < 0) {
      // 获取节点的后继
      Node s = node.next;
      // 如果是最后一个节点或者是等待共享读锁的节点
      if (s == null || s.isShared()) // 后继为空或者为共享模式
      // 以共享模式进行释放
      doReleaseShared();
      }
      }
    • 说明:该方法设置头结点并且释放头结点后面的满足条件的结点,该方法中可能会调用到AQS的doReleaseShared方法。

  • AQS的doReleaseShared方法其源码如下:

    • private void doReleaseShared() {
          /*
              * Ensure that a release propagates, even if there are other
              * in-progress acquires/releases.  This proceeds in the usual
              * way of trying to unparkSuccessor of head if it needs
              * signal. But if it does not, status is set to PROPAGATE to
              * ensure that upon release, propagation continues.
              * Additionally, we must loop in case a new node is added
              * while we are doing this. Also, unlike other uses of
              * unparkSuccessor, we need to know if CAS to reset status
              * fails, if so rechecking.
              */
          // 无限循环
          // 如果 head.waitStatus == Node.SIGNAL ==> 0 成功, 下一个节点 unpark
          // 如果 head.waitStatus == 0 ==> Node.PROPAGATE, 为了解决 bug, 见后面分析
          for (;;) {
              // 保存头结点
              Node h = head;
              // 队列还有节点
              if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点
                  // 获取头结点的等待状态
                  int ws = h.waitStatus; 
                  if (ws == Node.SIGNAL) { // 状态为SIGNAL
                      if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续
                          continue;            // loop to recheck cases
                      // 下一个节点 unpark 如果成功获取读锁
                      // 并且下下个节点还是 shared, 继续 doReleaseShared
                      // 释放后继结点
                      unparkSuccessor(h);
                  }
                  else if (
                      // 如果已经是 0 了,改为 -3,用来解决传播性,见后文信号量 bug 分析
                      ws == 0 &&
                      !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续
                      continue;                // loop on failed CAS
              }
              if (h == head) // 若头结点改变,继续循环  
                  break;
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
          
      - 说明:**该方法在共享模式下释放**,具体的流程再之后会通过一个示例给出。

      所以,对CountDownLatch的await调用大致会有如下的调用链:

      ![img](JUC/java-thread-x-countdownlatch-1.png)

      说明:上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。

      ###### 6、核心函数——countDown函数

      此函数将递减锁存器的计数,如果计数到达零,则释放所有等待的线程。

      ```java
      public void countDown() {
      sync.releaseShared(1);
      }

说明:对countDown的调用转换为对Sync对象的releaseShared(从AQS继承而来)方法的调用

  • releaseShared源码如下:

    • public final boolean releaseShared(int arg) {
          if (tryReleaseShared(arg)) {
              doReleaseShared();
              return true;
          }
          return false;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20

      - 说明:**此函数会以共享模式释放对象,并且在函数中会调用到CountDownLatch的tryReleaseShared函数,并且可能会调用AQS的doReleaseShared函数**。

      - tryReleaseShared源码如下:

      - ```java
      protected boolean tryReleaseShared(int releases) {
      // Decrement count; signal when transition to zero
      // 无限循环
      for (;;) {
      // 获取状态
      int c = getState();
      if (c == 0) // 没有被线程占有
      return false;
      // 下一个状态
      int nextc = c-1;
      if (compareAndSetState(c, nextc)) // 比较并且设置成功
      return nextc == 0;
      }
      }
    • 说明:此函数会试图设置状态来反映共享模式下的一个释放。具体的流程在下面的示例中会进行分析。

  • AQS的doReleaseShared的源码如下:

    • private void doReleaseShared() {
          /*
              * Ensure that a release propagates, even if there are other
              * in-progress acquires/releases.  This proceeds in the usual
              * way of trying to unparkSuccessor of head if it needs
              * signal. But if it does not, status is set to PROPAGATE to
              * ensure that upon release, propagation continues.
              * Additionally, we must loop in case a new node is added
              * while we are doing this. Also, unlike other uses of
              * unparkSuccessor, we need to know if CAS to reset status
              * fails, if so rechecking.
              */
          // 无限循环
          for (;;) {
              // 保存头结点
              Node h = head;
              if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点
                  // 获取头结点的等待状态
                  int ws = h.waitStatus; 
                  if (ws == Node.SIGNAL) { // 状态为SIGNAL
                      if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续
                          continue;            // loop to recheck cases
                      // 释放后继结点
                      unparkSuccessor(h);
                  }
                  else if (ws == 0 &&
                              !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续
                      continue;                // loop on failed CAS
              }
              if (h == head) // 若头结点改变,继续循环  
                  break;
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52

      - 说明:此函数在共享模式下释放资源。

      所以,对CountDownLatch的countDown调用大致会有如下的调用链:

      ![img](JUC/java-thread-x-countdownlatch-2.png)

      说明:上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。

      ##### 4、CountDownLatch示例

      下面给出了一个使用CountDownLatch的示例:

      ```java
      import java.util.concurrent.CountDownLatch;

      class MyThread extends Thread {
      private CountDownLatch countDownLatch;

      public MyThread(String name, CountDownLatch countDownLatch) {
      super(name);
      this.countDownLatch = countDownLatch;
      }

      public void run() {
      System.out.println(Thread.currentThread().getName() + " doing something");
      try {
      Thread.sleep(1000);
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName() + " finish");
      countDownLatch.countDown();
      }
      }

      public class CountDownLatchDemo {
      public static void main(String[] args) {
      CountDownLatch countDownLatch = new CountDownLatch(2);
      MyThread t1 = new MyThread("t1", countDownLatch);
      MyThread t2 = new MyThread("t2", countDownLatch);
      t1.start();
      t2.start();
      System.out.println("Waiting for t1 thread and t2 thread to finish");
      try {
      countDownLatch.await();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName() + " continue");
      }
      }

运行结果(某一次):

1
2
3
4
5
6
Waiting for t1 thread and t2 thread to finish
t1 doing something
t2 doing something
t1 finish
t2 finish
main continue

说明:本程序首先计数器初始化为2。根据结果,可能会存在如下的一种时序图:

img

说明:首先main线程会调用await操作,此时main线程会被阻塞,等待被唤醒,之后t1线程执行了countDown操作,最后,t2线程执行了countDown操作,此时main线程就被唤醒了,可以继续运行。下面,进行详细分析:

  • main线程执行countDownLatch.await操作,主要调用的函数如下:
    • img
    • 说明:在最后,main线程就被park了,即禁止运行了。此时Sync queue(同步队列)中有两个节点,AQS的state为2,包含main线程的结点的nextWaiter指向SHARED结点。
  • t1线程执行countDownLatch.countDown操作,主要调用的函数如下:
    • img
    • 说明:此时,Sync queue队列里的结点个数未发生变化,但是此时,AQS的state已经变为1了。
  • t2线程执行countDownLatch.countDown操作,主要调用的函数如下:
    • img
    • 说明:经过调用后,AQS的state为0,并且此时,main线程会被unpark,可以继续运行。当main线程获取cpu资源后,继续运行。
  • main线程获取cpu资源,继续运行,由于main线程是在parkAndCheckInterrupt函数中被禁止的,所以此时,继续在parkAndCheckInterrupt函数运行。
    • img
    • 说明:main线程恢复,继续在parkAndCheckInterrupt函数中运行,之后又会回到最终达到的状态为:AQS的state为0,并且head与tail指向同一个结点,该节点的nextWaiter域还是指向SHARED结点。
5、更深入理解
1、面试题

实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束。

2、使用wait和notify实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.ArrayList;
import java.util.List;

/**
* 必须先让t2先进行启动 使用wait 和 notify 进行相互通讯,wait会释放锁,notify不会释放锁
*/
public class T2 {

volatile List list = new ArrayList();

public void add (int i){
list.add(i);
}

public int getSize(){
return list.size();
}

public static void main(String[] args) {

T2 t2 = new T2();

Object lock = new Object();

new Thread(() -> {
synchronized(lock){
System.out.println("t2 启动");
if(t2.getSize() != 5){
try {
/**会释放锁*/
lock.wait();
System.out.println("t2 结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**唤醒T1进程*/
lock.notify();
}
},"t2").start();

new Thread(() -> {
synchronized (lock){
System.out.println("t1 启动");
for (int i=0;i<9;i++){
t2.add(i);
System.out.println("add"+i);
if(t2.getSize() == 5){
/**不会释放锁*/
lock.notify();
try {
/**进程挂起,释放锁等待唤醒*/
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
},"t1").start();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
t2 启动
t1 启动
add0
add1
add2
add3
add4
t2 结束
add5
add6
add7
add8
3、CountDownLatch实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/**
* 使用CountDownLatch 代替wait notify 好处是通讯方式简单,不涉及锁定 Count 值为0时当前线程继续执行,
*/
public class T3 {

volatile List list = new ArrayList();

public void add(int i){
list.add(i);
}

public int getSize(){
return list.size();
}


public static void main(String[] args) {
T3 t = new T3();
CountDownLatch countDownLatch = new CountDownLatch(1);

new Thread(() -> {
System.out.println("t2 start");
if(t.getSize() != 5){
try {
countDownLatch.await();
System.out.println("t2 end");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t2").start();

new Thread(()->{
System.out.println("t1 start");
for (int i = 0;i<9;i++){
t.add(i);
System.out.println("add"+ i);
if(t.getSize() == 5){
System.out.println("countdown is open");
countDownLatch.countDown();
}
}
System.out.println("t1 end");
},"t1").start();
}

}

2、CyclicBarrier(循环栅栏)

CyclicBarrier底层是基于ReentrantLockAbstractQueuedSynchronizer来实现的,在理解的时候最好和CountDownLatch放在一起理解。

1、BAT大厂的面试问题
  • 什么是CyclicBarrier?
  • CyclicBarrier底层实现原理?
  • CountDownLatch和CyclicBarrier对比?
  • CyclicBarrier的核心函数有哪些?
  • CyclicBarrier适用于什么场景?
2、CyclicBarrier简介
  • 对于CountDownLatch,其他线程为游戏玩家,比如英雄联盟,主线程为控制游戏开始的线程。在所有的玩家都准备好之前,主线程是处于等待状态的,也就是游戏不能开始。当所有的玩家准备好之后,下一步的动作实施者为主线程,即开始游戏。
  • 对于CyclicBarrier,假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推。类比地,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。
  • 注意:CyclicBarrier的计数与线程数最好是一一对应才能达到我们的要求
    • 例子:一开始两个任务task1(执行1s)与task2(执行2s),需要循环执行3次,即两对三次总共六次任务,每一对任务执行完毕会执行CyclicBarrier当中的任务task3,所以设置CyclicBarrier的计数为2,对应每一组两个任务
    • 如果我们设置线程池的线程个数为2,那么会如我们所想执行——task1 task2 task3 task1 task2 task3 task1 task2 task3
    • 如果我们设置线程池的线程个数为3,那么就不会如我们所想执行了,因为一开始会有三个线程线执行任务:task1 task2 task1,而CyclicBarrier的task3会被两个task1执行(因为1 + 1 = 2)结束后执行,执行流程就变成——task1 task1 task3 task2 task1 task3 task2 task2 task3
3、CyclicBarrier源码分析
1、类的继承关系

CyclicBarrier没有显示继承哪个父类或者实现哪个父接口,所有AQS和重入锁不是通过继承实现的,而是通过组合实现的。

1
public class CyclicBarrier {}
2、类的内部类

CyclicBarrier类存在一个内部类Generation,每一次使用的CycBarrier可以当成Generation的实例,其源代码如下:

1
2
3
private static class Generation {
boolean broken = false;
}

说明:Generation类有一个属性broken,用来表示当前屏障是否被损坏

3、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CyclicBarrier {
/** The lock for guarding barrier entry */
// 可重入锁
private final ReentrantLock lock = new ReentrantLock();
/** Condition to wait on until tripped */
// 条件队列
private final Condition trip = lock.newCondition();
/** The number of parties */
// 参与的线程数量
private final int parties;
/* The command to run when tripped */
// 由最后一个进入 barrier 的线程执行的操作
private final Runnable barrierCommand;
/** The current generation */
// 当前代
private Generation generation = new Generation();
// 正在等待进入屏障的线程数量
private int count;
}

说明:该属性有一个为ReentrantLock对象,有一个为Condition对象,而Condition对象又是基于AQS的,所以,归根到底,底层还是由AQS提供支持

4、类的构造函数
  • CyclicBarrier(int, Runnable)型构造函数:

    • public CyclicBarrier(int parties, Runnable barrierAction) {
          // 参与的线程数量小于等于0,抛出异常
          if (parties <= 0) throw new IllegalArgumentException();
          // 设置parties
          this.parties = parties;
          // 设置count
          this.count = parties;
          // 设置barrierCommand
          this.barrierCommand = barrierAction;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 说明:该构造函数可以指定关联该CyclicBarrier的线程数量,并且可以指定在所有线程都进入屏障后的执行动作,该执行动作由最后一个进行屏障的线程执行。

      - CyclicBarrier(int)型构造函数:

      - ```java
      public CyclicBarrier(int parties) {
      // 调用含有两个参数的构造函数
      this(parties, null);
      }

    • 说明:该构造函数仅仅执行了关联该CyclicBarrier的线程数量,没有设置执行动作。

5、核心函数——dowait函数

此函数为CyclicBarrier类的核心函数,CyclicBarrier类对外提供的await函数在底层都是调用该类的doawait函数

await函数源代码如下:

1
2
3
4
5
6
7
public int await() throws InterruptedException, BrokenBarrierException {
try {
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe); // cannot happen
}
}

doawait函数源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
// 保存当前锁
final ReentrantLock lock = this.lock;
// 锁定
lock.lock();
try {
// 保存当前代
final Generation g = generation;

if (g.broken) // 屏障被破坏,抛出异常
throw new BrokenBarrierException();

if (Thread.interrupted()) { // 线程被中断
// 损坏当前屏障,并且唤醒所有的线程,只有拥有锁的时候才会调用
breakBarrier();
// 抛出异常
throw new InterruptedException();
}

// 减少正在等待进入屏障的线程数量
int index = --count;
if (index == 0) { // 正在等待进入屏障的线程数量为0,所有线程都已经进入
// 运行的动作标识
boolean ranAction = false;
try {
// 保存运行动作
final Runnable command = barrierCommand;
if (command != null) // 动作不为空
// 运行
command.run();
// 设置ranAction状态
ranAction = true;
// 进入下一代
nextGeneration();
return 0;
} finally {
if (!ranAction) // 没有运行的动作
// 损坏当前屏障
breakBarrier();
}
}

// loop until tripped, broken, interrupted, or timed out
// 无限循环
for (;;) {
try {
if (!timed) // 没有设置等待时间
// 等待
trip.await();
else if (nanos > 0L) // 设置了等待时间,并且等待时间大于0
// 等待指定时长
nanos = trip.awaitNanos(nanos);
} catch (InterruptedException ie) {
if (g == generation && ! g.broken) { // 等于当前代并且屏障没有被损坏
// 损坏当前屏障
breakBarrier();
// 抛出异常
throw ie;
} else { // 不等于当前带后者是屏障被损坏
// We're about to finish waiting even if we had not
// been interrupted, so this interrupt is deemed to
// "belong" to subsequent execution.
// 中断当前线程
Thread.currentThread().interrupt();
}
}

if (g.broken) // 屏障被损坏,抛出异常
throw new BrokenBarrierException();

if (g != generation) // 不等于当前代
// 返回索引
return index;

if (timed && nanos <= 0L) { // 设置了等待时间,并且等待时间小于0
// 损坏屏障
breakBarrier();
// 抛出异常
throw new TimeoutException();
}
}
} finally {
// 释放锁
lock.unlock();
}
}

说明:dowait方法的逻辑会进行一系列的判断,大致流程如下:

img

6、核心函数——nextGenneration函数

此函数在所有线程进入屏障后会被调用,即生成下一个版本,所有线程又可以重新进入到屏障中,其源代码如下:

1
2
3
4
5
6
7
8
9
10
private void nextGeneration() {
// signal completion of last generation
// 唤醒所有线程
trip.signalAll();
// set up next generation
// 恢复正在等待进入屏障的线程数量
count = parties;
// 新生一代
generation = new Generation();
}

在此函数中会调用AQS的signalAll方法,即唤醒所有等待线程

如果所有的线程都在等待此条件,则唤醒所有线程。其源代码如下:

1
2
3
4
5
6
7
8
9
public final void signalAll() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒所有等待线程
doSignalAll(first);
}

说明:此函数判断头结点是否为空,即条件队列是否为空,然后会调用doSignalAll函数,doSignalAll函数源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 全部唤醒 - 等待队列的所有节点转移至 AQS 队列
private void doSignalAll(Node first) {
// condition队列的头结点尾结点都设置为空
lastWaiter = firstWaiter = null;
// 循环
do {
// 获取first结点的nextWaiter域结点
Node next = first.nextWaiter;
// 设置first结点的nextWaiter域为空
first.nextWaiter = null;
// 将first结点从condition队列转移到sync队列
transferForSignal(first);
// 重新设置first
first = next;
} while (first != null);
}

说明:此函数会依次将条件队列中的节点转移到同步队列中,会调用到transferForSignal函数,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 如果节点状态是取消, 返回 false 表示转移失败, 否则转移成功
final boolean transferForSignal(Node node) {
/*
* If cannot change waitStatus, the node has been cancelled.
*/
// 如果状态已经不是 Node.CONDITION, 说明被取消了
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;

/*
* Splice onto queue and try to set waitStatus of predecessor to
* indicate that thread is (probably) waiting. If cancelled or
* attempt to set waitStatus fails, wake up to resync (in which
* case the waitStatus can be transiently and harmlessly wrong).
*/
// 加入 AQS 队列尾部
Node p = enq(node);
int ws = p.waitStatus;
if (
// 上一个节点被取消
ws > 0 ||
// 上一个节点不能设置状态为 Node.SIGNAL
!compareAndSetWaitStatus(p, ws, Node.SIGNAL))
// unpark 取消阻塞, 让线程重新同步状态
LockSupport.unpark(node.thread);
return true;
}

说明:此函数的作用就是将处于条件队列中的节点转移到同步队列中,并设置结点的状态信息

其中会调用到enq函数,其源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Node enq(final Node node) {
for (;;) { // 无限循环,确保结点能够成功入队列
// 保存尾结点
Node t = tail;
if (t == null) { // 尾结点为空,即还没被初始化
if (compareAndSetHead(new Node())) // 头结点为空,并设置头结点为新生成的结点
tail = head; // 头结点与尾结点都指向同一个新生结点
} else { // 尾结点不为空,即已经被初始化过
// 将node结点的prev域连接到尾结点
node.prev = t;
if (compareAndSetTail(t, node)) { // 比较结点t是否为尾结点,若是则将尾结点设置为node
// 设置尾结点的next域为node
t.next = node;
return t; // 返回尾结点
}
}
}
}

说明:此函数完成了结点插入同步队列的过程,也很好理解。

综合上面的分析可知,newGeneration函数的主要方法的调用如下,之后会通过一个例子详细讲解:

img

7、breakBarrier函数

此函数的作用是损坏当前屏障,会唤醒所有在屏障中的线程。源代码如下:

1
2
3
4
5
6
7
8
private void breakBarrier() {
// 设置状态
generation.broken = true;
// 恢复正在等待进入屏障的线程数量
count = parties;
// 唤醒所有线程
trip.signalAll();
}

说明:可以看到,此函数也调用了AQS的signalAll函数,由signal函数提供支持。

4、CyclicBarrier示例

下面通过一个例子来详解CyclicBarrier的使用和内部工作机制,源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

class MyThread extends Thread {
private CyclicBarrier cb;
public MyThread(String name, CyclicBarrier cb) {
super(name);
this.cb = cb;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " going to await");
try {
cb.await();
System.out.println(Thread.currentThread().getName() + " continue");
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class CyclicBarrierDemo {
public static void main(String[] args) throws InterruptedException, BrokenBarrierException {
CyclicBarrier cb = new CyclicBarrier(3, new Thread("barrierAction") {
public void run() {
System.out.println(Thread.currentThread().getName() + " barrier action");
}
});
MyThread t1 = new MyThread("t1", cb);
MyThread t2 = new MyThread("t2", cb);
t1.start();
t2.start();
System.out.println(Thread.currentThread().getName() + " going to await");
cb.await();
System.out.println(Thread.currentThread().getName() + " continue");

}
}

运行结果(某一次):

1
2
3
4
5
6
7
t1 going to await
main going to await
t2 going to await
t2 barrier action
t2 continue
t1 continue
main continue

说明:根据结果可知,可能会存在如下的调用时序:

java-thread-x-cyclicbarrier-3

说明:由上图可知,假设t1线程的cb.await是在main线程的cb.await之前,cb.barrierAction动作是由最后一个进入屏障的线程t2执行的。根据时序图,进一步分析出其内部工作流程。

  • main(主)线程执行cb.await操作,主要调用的函数如下:
    • img
    • 说明:由于ReentrantLock的默认采用非公平策略,所以在dowait函数中调用的是ReentrantLock.NonfairSync的lock函数,由于此时AQS的状态是0,表示还没有被任何线程占用,故main线程可以占用,之后在dowait中会调用trip.await函数,最终的结果是条件队列中存放了一个包含main线程的结点,并且被禁止运行了,同时,main线程所拥有的资源也被释放了,可以供其他线程获取。
  • t1线程执行cb.await操作,其中假设t1线程的lock.lock操作在main线程释放了资源之后,则其主要调用的函数如下:
    • img
    • 说明:可以看到,之后condition queue(条件队列)里面有两个节点,包含t1线程的结点插入在队列的尾部,并且t1线程也被禁止了,因为执行了park操作,此时两个线程都被禁止了。
  • t2线程执行cb.await操作,其中假设t2线程的lock.lock操作在t1线程释放了资源之后,则其主要调用的函数如下:
    • img
    • 说明:由上图可知,在t2线程执行await操作后,会直接执行command.run方法,不是重新开启一个线程,而是最后进入屏障的线程执行。同时,会将Condition queue中的所有节点都转移到Sync queue中,并且最后main线程会被unpark,可以继续运行。main线程获取cpu资源,继续运行。
  • main线程获取cpu资源,继续运行,下图给出了主要的方法调用:
    • img
    • 说明:其中,由于main线程是在AQS.CO的wait中被park的,所以恢复时,会继续在该方法中运行。运行过后,t1线程被unpark,它获得cpu资源可以继续运行。
  • t1线程获取cpu资源,继续运行,下图给出了主要的方法调用:
    • img
    • 说明:其中,由于t1线程是在AQS.CO的wait方法中被park,所以恢复时,会继续在该方法中运行。运行过后,Sync queue中保持着一个空节点。头结点与尾节点均指向它。

注意:在线程await过程中中断线程会抛出异常,所有进入屏障的线程都将被释放。至于CyclicBarrier的其他用法,读者可以自行查阅API。

5、新增一个容易理解的例子

场景:收集七龙珠召唤神龙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

//集齐7颗龙珠就可以召唤神龙
public class CyclicBarrierDemo {

//创建固定值
private static final int NUMBER = 7;

public static void main(String[] args) {
//创建CyclicBarrier
CyclicBarrier cyclicBarrier =
new CyclicBarrier(NUMBER,()->{
System.out.println("集齐7颗龙珠就可以召唤神龙");
});

//集齐七颗龙珠过程
for (int i = 1; i <=7; i++) {
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+" 星龙珠被收集到了");
//等待
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
}

某一次执行结果:

1
2
3
4
5
6
7
8
2 星龙珠被收集到了
4 星龙珠被收集到了
3 星龙珠被收集到了
1 星龙珠被收集到了
7 星龙珠被收集到了
6 星龙珠被收集到了
5 星龙珠被收集到了
集齐7颗龙珠就可以召唤神龙

若将主线程的循环从7改成6的话,由于只能收集到6颗龙珠,所以不能召唤神龙(其实就是没能达到破坏屏障的条件,所有的线程都在等待)

1
2
3
4
5
6
2 星龙珠被收集到了
5 星龙珠被收集到了
4 星龙珠被收集到了
3 星龙珠被收集到了
1 星龙珠被收集到了
6 星龙珠被收集到了
6、和CountDownLatch再对比
  • CountDownLatch减计数,CyclicBarrier加计数。
  • CountDownLatch是一次性的,CyclicBarrier可以重用。
  • CountDownLatch和CyclicBarrier都有让多个线程等待同步然后再开始下一步动作的意思,但是CountDownLatch的下一步的动作实施者是主线程,具有不可重复性;而CyclicBarrier的下一步动作实施者还是“其他线程”本身,具有往复多次实施动作的特点。

3、Semaphore(信号量)

Semaphore底层是基于AbstractQueuedSynchronizer(AQS)来实现的。Semaphore称为计数信号量,它允许n个任务同时访问某个资源,可以将信号量看做是在向外分发使用资源的许可证,只有成功获取许可证,才能使用资源,用来限制能同时访问共享资源的线程上限。

1、BAT大厂的面试问题
  • 什么是Semaphore?
  • Semaphore内部原理?
  • Semaphore常用方法有哪些?如何实现线程同步和互斥的?
  • Semaphore适合用在什么场景?
  • 单独使用Semaphore是不会使用到AQS的条件队列?
  • Semaphore中申请令牌(acquire)、释放令牌(release)的实现?
  • Semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?
  • Semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?
  • Semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?
  • Semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?
2、Semaphore源码分析
1、类的继承关系
1
public class Semaphore implements java.io.Serializable {}

说明:Semaphore实现了Serializable接口,即可以进行序列化

2、类的内部类

Semaphore总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系:

image

说明:Semaphore与ReentrantLock的内部类的结构相同,类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。

3、类的内部类——Sync类

Sync类的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 内部类,继承自AQS
abstract static class Sync extends AbstractQueuedSynchronizer {
// 版本号
private static final long serialVersionUID = 1192457210091910933L;

// 构造函数
Sync(int permits) {
// 设置状态数
// permits 即 state
setState(permits);
}

// 获取许可
final int getPermits() {
return getState();
}

// 共享模式下非公平策略获取
final int nonfairTryAcquireShared(int acquires) {
for (;;) { // 无限循环
// 获取许可数
int available = getState();
// 剩余的许可
int remaining = available - acquires;
if (
// 如果许可已经用完, 返回负数, 表示获取失败, 进入 doAcquireSharedInterruptibly
remaining < 0 ||
// 如果 cas 重试成功, 返回正数, 表示获取成功
compareAndSetState(available, remaining)) // 许可小于0或者比较并且设置状态成功
return remaining;
}
}

// 共享模式下进行释放
protected final boolean tryReleaseShared(int releases) {
for (;;) { // 无限循环
// 获取许可
int current = getState();
// 可用的许可
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next)) // 比较并进行设置成功
return true;
}
}

// 根据指定的缩减量减小可用许可的数目
final void reducePermits(int reductions) {
for (;;) { // 无限循环
// 获取许可
int current = getState();
// 可用的许可
int next = current - reductions;
if (next > current) // underflow
throw new Error("Permit count underflow");
if (compareAndSetState(current, next)) // 比较并进行设置成功
return;
}
}

// 获取并返回立即可用的所有许可
final int drainPermits() {
for (;;) { // 无限循环
// 获取许可
int current = getState();
if (current == 0 || compareAndSetState(current, 0)) // 许可为0或者比较并设置成功
return current;
}
}
}

说明:Sync类的属性相对简单,只有一个版本号,Sync类存在如下方法和作用如下:

img

4、类的内部类——NonfairSync类

NonfairSync类继承了Sync类,表示采用非公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
static final class NonfairSync extends Sync {
// 版本号
private static final long serialVersionUID = -2694183684443567898L;

// 构造函数
NonfairSync(int permits) {
super(permits);
}
// 共享模式下获取
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
}

说明:从tryAcquireShared方法的源码可知,其会调用父类Sync的nonfairTryAcquireShared方法,表示按照非公平策略进行资源的获取。

5、类的内部类——FairSync类

FairSync类继承了Sync类,表示采用公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
protected int tryAcquireShared(int acquires) {
for (;;) { // 无限循环
if (hasQueuedPredecessors()) // 同步队列中存在其他节点
return -1;
// 获取许可
int available = getState();
// 剩余的许可
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining)) // 剩余的许可小于0或者比较设置成功
return remaining;
}
}

说明:从tryAcquireShared方法的源码可知,它使用公平策略来获取资源,它会判断同步队列中是否存在其他的等待节点

6、类的属性
1
2
3
4
5
6
public class Semaphore implements java.io.Serializable {
// 版本号
private static final long serialVersionUID = -3222578661600680210L;
// 属性
private final Sync sync;
}

说明:Semaphore自身只有两个属性,最重要的是sync属性,基于Semaphore对象的操作绝大多数都转移到了对sync的操作

7、类的构造函数
  • Semaphore(int)型构造函数

    • public Semaphore(int permits) {
          sync = new NonfairSync(permits);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - 说明:该构造函数会创建具有给定的许可数和**非公平的设置的Semaphore**。

      - Semaphore(int, boolean)型构造函数

      - ```java
      public Semaphore(int permits, boolean fair) {
      sync = fair ? new FairSync(permits) : new NonfairSync(permits);
      }
    • 说明:该构造函数会创建具有给定的许可数和给定的公平设置的Semaphore

8、核心函数——acquire函数

此方法从信号量获取一个(多个)许可,在提供一个许可前一直将线程阻塞,或者线程被中断,其源码如下:

1
2
3
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
1
2
3
4
public void acquire(int permits) throws InterruptedException {
if (permits < 0) throw new IllegalArgumentException();
sync.acquireSharedInterruptibly(permits);
}

说明:该方法中将会调用Sync对象的acquireSharedInterruptibly(从AQS继承而来的方法)方法,而acquireSharedInterruptibly方法在上面CountDownLatch中已经进行了分析,在此不再累赘。

最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:

img

说明:上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。

9、核心函数——release函数

此方法释放一个(多个)许可,将其返回给信号量,源码如下:

1
2
3
public void release() {
sync.releaseShared(1);
}

说明:该方法中将会调用Sync对象的releaseShared(从AQS继承而来的方法)方法,而releaseShared方法在上面CountDownLatch中已经进行了分析,在此不再累赘。

最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:

img

说明:上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。

10、图解Semaphore的执行流程

加锁解锁流程

  1. Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后停车场显示空余车位减一

  2. 刚开始,permits(state)为 3,这时 5 个线程来获取资源

    image-20210813191406684

  3. 假设其中 Thread-1,Thread-2,Thread-4 cas 竞争成功,而 Thread-0 和 Thread-3 竞争失败,进入 AQS 队列park 阻塞

    image-20210813192404706

  4. 这时 Thread-4 释放了 permits,状态如下

    image-20210813192655068

  5. 接下来 Thread-0 竞争成功,permits 再次设置为 0,设置自己为 head 节点,断开原来的 head 节点,unpark 接下来的 Thread-3 节点,但由于 permits 是 0,因此 Thread-3 在尝试不成功后再次进入 park 状态

    image-20210813193133463

3、Semaphore示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.util.concurrent.Semaphore;

class MyThread extends Thread {
private Semaphore semaphore;

public MyThread(String name, Semaphore semaphore) {
super(name);
this.semaphore = semaphore;
}

public void run() {
int count = 3;
System.out.println(Thread.currentThread().getName() + " trying to acquire");
try {
semaphore.acquire(count);
System.out.println(Thread.currentThread().getName() + " acquire successfully");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(count);
System.out.println(Thread.currentThread().getName() + " release successfully");
}
}
}

public class SemaphoreDemo {
public final static int SEM_SIZE = 10;

public static void main(String[] args) {
Semaphore semaphore = new Semaphore(SEM_SIZE);
MyThread t1 = new MyThread("t1", semaphore);
MyThread t2 = new MyThread("t2", semaphore);
t1.start();
t2.start();
int permits = 5;
System.out.println(Thread.currentThread().getName() + " trying to acquire");
try {
semaphore.acquire(permits);
System.out.println(Thread.currentThread().getName() + " acquire successfully");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
System.out.println(Thread.currentThread().getName() + " release successfully");
}
}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
main trying to acquire
main acquire successfully
t1 trying to acquire
t1 acquire successfully
t2 trying to acquire
t1 release successfully
main release successfully
t2 acquire successfully
t2 release successfully

说明:首先,生成一个信号量,信号量有10个许可,然后,main,t1,t2三个线程获取许可运行,根据结果,可能存在如下的一种时序:

img

说明:如上图所示,首先,main线程执行acquire操作,并且成功获得许可,之后t1线程执行acquire操作,成功获得许可,之后t2执行acquire操作,由于此时许可数量不够,t2线程将会阻塞,直到许可可用。之后t1线程释放许可,main线程释放许可,此时的许可数量可以满足t2线程的要求,所以,此时t2线程会成功获得许可运行,t2运行完成后释放许可。下面进行详细分析:

  • main线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到只是AQS的state变为了5,main线程并没有被阻塞,可以继续运行。
  • t1线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到只是AQS的state变为了2,t1线程并没有被阻塞,可以继续运行。
  • t2线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程获取许可不会成功,之后会导致其被禁止运行,值得注意的是,AQS的state还是为2。
  • t1执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程将会被unpark,并且AQS的state为5,t2获取cpu资源后可以继续运行。
  • main线程执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程还会被unpark,但是不会产生影响,此时,只要t2线程获得CPU资源就可以运行了。此时,AQS的state为10。
  • t2获取CPU资源,继续运行,此时t2需要恢复现场,回到parkAndCheckInterrupt函数中,也是在should继续运行。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到,Sync queue中只有一个结点,头结点与尾节点都指向该结点,在setHeadAndPropagate的函数中会设置头结点并且会unpark队列中的其他结点。
  • t2线程执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:t2线程经过release后,此时信号量的许可又变为10个了,此时Sync queue中的结点还是没有变化。
4、新增一个容易理解的例子

场景:6辆汽车,停3个车位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.Random;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

//6辆汽车,停3个车位
public class SemaphoreDemo {
public static void main(String[] args) {
//创建Semaphore,设置许可数量
Semaphore semaphore = new Semaphore(3);

//模拟6辆汽车
for (int i = 1; i <=6; i++) {
new Thread(()->{
try {
//抢占
semaphore.acquire();

System.out.println(Thread.currentThread().getName()+" 抢到了车位");

//设置随机停车时间
TimeUnit.SECONDS.sleep(new Random().nextInt(5));

System.out.println(Thread.currentThread().getName()+" ------离开了车位");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放
semaphore.release();
}
},String.valueOf(i)).start();
}
}
}

某一次执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
1 抢到了车位
2 抢到了车位
3 抢到了车位
2 ------离开了车位
4 抢到了车位
1 ------离开了车位
3 ------离开了车位
5 抢到了车位
6 抢到了车位
4 ------离开了车位
5 ------离开了车位
6 ------离开了车位
5、Semaphore应用
  • 使用Semaphore限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比Tomcat LimitLatch的实现)
  • Semaphore比较适用于资源数与线程数相等的场景
  • 用 Semaphore 实现简单连接池(一个线程对应一个数据库连接),对比享元模式下的实现(用wait notify),性能和可读性显然更好,注意下面的实现中线程数和数据库连接数是相等的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import lombok.extern.slf4j.Slf4j;

import java.sql.*;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicIntegerArray;

public class TestPoolSemaphore {
public static void main(String[] args) {
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
}
}

@Slf4j(topic = "c.Pool")
class Pool {
// 1. 连接池大小
private final int poolSize;

// 2. 连接对象数组
private Connection[] connections;

// 3. 连接状态数组 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;

private Semaphore semaphore;

// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
// 让许可数与资源数一致
this.semaphore = new Semaphore(poolSize);
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i+1));
}
}

// 5. 借连接
public Connection borrow() {// t1, t2, t3
// 获取许可
try {
semaphore.acquire(); // 没有许可的线程,在此等待
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 不会执行到这里
return null;
}
// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
log.debug("free {}", conn);
semaphore.release();
break;
}
}
}
}

class MockConnection implements Connection {

private String name;

public MockConnection(String name) {
this.name = name;
}

@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}

// 一些重写的方法
}
6、更深入理解
1、单独使用Semaphore是不会使用到AQS的条件队列的

不同于CyclicBarrier和ReentrantLock,单独使用Semaphore是不会使用到AQS的条件队列的,其实,只有进行await操作才会进入条件队列,其他的都是在同步队列中,只是当前线程会被park。

2、场景问题——semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?

答案:拿不到令牌的线程阻塞,不会继续往下运行。

3、场景问题——semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?

答案:线程阻塞,不会继续往下运行。可能你会考虑类似于锁的重入的问题,很好,但是,令牌没有重入的概念。你只要调用一次acquire方法,就需要有一个令牌才能继续运行。(这和你一次性申请11个令牌是一样的)

4、场景问题——semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?

答案:能,原因是release方法会添加令牌,并不会以初始化的大小为准。

5、场景问题——semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?

答案:能,原因是release会添加令牌,并不会以初始化的大小为准。Semaphore中release方法的调用并没有限制要在acquire后调用。

具体示例如下,如果不相信的话,可以运行一下下面的demo,在做实验之前,笔者也认为应该是不允许的。。(或许是开发者考虑不周到)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestSemaphore2 {
public static void main(String[] args) {
int permitsNum = 2;
final Semaphore semaphore = new Semaphore(permitsNum);
try {
System.out.println("availablePermits:"+semaphore.availablePermits()+",semaphore.tryAcquire(3,1, TimeUnit.SECONDS):"+semaphore.tryAcquire(3,1, TimeUnit.SECONDS));
semaphore.release();
System.out.println("availablePermits:"+semaphore.availablePermits()+",semaphore.tryAcquire(3,1, TimeUnit.SECONDS):"+semaphore.tryAcquire(3,1, TimeUnit.SECONDS));
}catch (Exception e) {

}
}
}

4、Phaser(移相器)

Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。

1、BAT大厂的面试问题
  • Phaser主要用来解决什么问题?
  • Phaser与CyclicBarrier和CountDownLatch的区别是什么?
  • 如果用CountDownLatch来实现Phaser的功能应该怎么实现?
  • Phaser运行机制是什么样的?
  • 给一个Phaser使用的示例?
2、Phaser运行机制

java-thread-x-juc-phaser-1

1、Registration(注册)

跟其他barrier不同,在phaser上注册的parties会随着时间的变化而变化。任务可以随时注册(使用方法register,bulkRegister注册,或者由构造器确定初始parties),并且在任何抵达点可以随意地撤销注册(方法arriveAndDeregister)。就像大多数基本的同步结构一样,注册和撤销只影响内部count;不会创建更深的内部记录,所以任务不能查询他们是否已经注册。(不过,可以通过继承来实现类似的记录)

2、Synchronization(同步机制)

和CyclicBarrier一样,Phaser也可以重复await。方法arriveAndAwaitAdvance的效果类似CyclicBarrier.await。phaser的每一代都有一个相关的phase number,初始值为0,当所有注册的任务都到达phaser时phase+1,到达最大值(Integer.MAX_VALUE)之后清零。使用phase number可以独立控制 ==到达phaser== 和 ==等待其他线程== 的动作,通过下面两种类型的方法:

  • Arrival(到达机制) arrive和arriveAndDeregister方法记录到达状态。这些方法不会阻塞,但是会返回一个相关的arrival phase number;也就是说,phase number用来确定到达状态。当所有任务都到达给定phase时,可以执行一个可选的函数,这个函数通过重写onAdvance方法实现,通常可以用来控制终止状态。重写此方法类似于为CyclicBarrier提供一个barrierAction,但比它更灵活。
  • Waiting(等待机制) awaitAdvance方法需要一个表示arrival phase number的参数,并且在phaser前进到与给定phase不同的phase时返回。和CyclicBarrier不同,即使等待线程已经被中断,awaitAdvance方法也会一直等待。中断状态和超时时间同样可用,但是当任务等待中断或超时后未改变phaser的状态时会遭遇异常。如果有必要,在方法forceTermination之后可以执行这些异常的相关的handler进行恢复操作,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务
3、Termination(终止机制)

可以用isTerminated方法检查phaser的终止状态。在终止时,所有同步方法立刻返回一个负值。在终止时尝试注册也没有效果。当调用onAdvance返回true时Termination被触发。当deregistration操作使已注册的parties变为0时,onAdvance的默认实现就会返回true。也可以重写onAdvance方法来定义终止动作。forceTermination方法也可以释放等待线程并且允许它们终止。

4、Tiering(分层结构)

Phaser支持分层结构(树状构造)来减少竞争。注册了大量parties的Phaser可能会因为同步竞争消耗很高的成本, 因此可以设置一些子Phaser来共享一个通用的parent。这样的话即使每个操作消耗了更多的开销,但是会提高整体吞吐量。 在一个分层结构的phaser里,子节点phaser的注册和取消注册都通过父节点管理。子节点phaser通过构造或方法register、bulkRegister进行首次注册时,在其父节点上注册。子节点phaser通过调用arriveAndDeregister进行最后一次取消注册时,也在其父节点上取消注册。

5、Monitoring(状态监控)

由于同步方法可能只被已注册的parties调用,所以phaser的当前状态也可能被任何调用者监控。在任何时候,可以通过getRegisteredParties获取parties数,其中getArrivedParties方法返回已经到达当前phase的parties数。当剩余的parties(通过方法getUnarrivedParties获取)到达时,phase进入下一代。这些方法返回的值可能只表示短暂的状态,所以一般来说在同步结构里并没有啥卵用。

3、Phaser源码详解
1、核心参数
1
2
3
4
5
6
7
8
9
10
11
12
private volatile long state;
/**
* The parent of this phaser, or null if none
*/
private final Phaser parent;
/**
* The root of phaser tree. Equals this if not in a tree.
*/
private final Phaser root;
//等待线程的栈顶元素,根据phase取模定义为一个奇数header和一个偶数header
private final AtomicReference<QNode> evenQ;
private final AtomicReference<QNode> oddQ;

state状态说明:Phaser使用一个long型state值来标识内部状态:

  • 低0-15位表示未到达parties数
  • 中16-31位表示等待的parties数
  • 中32-62位表示phase当前代
  • 高63位表示当前phaser的终止状态

注意:子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。这里在后面源码分析的reconcileState方法里会讲解。 Qnode是Phaser定义的内部等待队列,用于在阻塞时记录等待线程及相关信息。实现了ForkJoinPool的一个内部接口ManagedBlocker,上面已经说过,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务(通过内部实现方法isReleasable和block)。

2、函数列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//构造方法
public Phaser() {
this(null, 0);
}
public Phaser(int parties) {
this(null, parties);
}
public Phaser(Phaser parent) {
this(parent, 0);
}
public Phaser(Phaser parent, int parties)
//注册一个新的party
public int register()
//批量注册
public int bulkRegister(int parties)
//使当前线程到达phaser,不等待其他任务到达。返回arrival phase number
public int arrive()
//使当前线程到达phaser并撤销注册,返回arrival phase number
public int arriveAndDeregister()
/*
* 使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。
* 如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。
* 如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。
*/
public int arriveAndAwaitAdvance()
//等待给定phase数,返回下一个 arrival phase number
public int awaitAdvance(int phase)
//阻塞等待,直到phase前进到下一代,返回下一代的phase number
public int awaitAdvance(int phase)
//响应中断版awaitAdvance
public int awaitAdvanceInterruptibly(int phase) throws InterruptedException
public int awaitAdvanceInterruptibly(int phase, long timeout, TimeUnit unit)
throws InterruptedException, TimeoutException
//使当前phaser进入终止状态,已注册的parties不受影响,如果是分层结构,则终止所有phaser
public void forceTermination()
3、方法——register()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//注册一个新的party
public int register() {
return doRegister(1);
}
private int doRegister(int registrations) {
// adjustment to state
long adjust = ((long)registrations << PARTIES_SHIFT) | registrations;
final Phaser parent = this.parent;
int phase;
for (;;) {
long s = (parent == null) ? state : reconcileState();
int counts = (int)s;
int parties = counts >>> PARTIES_SHIFT;//获取已注册parties数
int unarrived = counts & UNARRIVED_MASK;//未到达数
if (registrations > MAX_PARTIES - parties)
throw new IllegalStateException(badRegister(s));
phase = (int)(s >>> PHASE_SHIFT);//获取当前代
if (phase < 0)
break;
if (counts != EMPTY) { // not 1st registration
if (parent == null || reconcileState() == s) {
if (unarrived == 0) // wait out advance
root.internalAwaitAdvance(phase, null);//等待其他任务到达
else if (UNSAFE.compareAndSwapLong(this, stateOffset,
s, s + adjust))//更新注册的parties数
break;
}
}
else if (parent == null) { // 1st root registration
long next = ((long)phase << PHASE_SHIFT) | adjust;
if (UNSAFE.compareAndSwapLong(this, stateOffset, s, next))//更新phase
break;
}
else {
//分层结构,子phaser首次注册用父节点管理
synchronized (this) { // 1st sub registration
if (state == s) { // recheck under lock
phase = parent.doRegister(1);//分层结构,使用父节点注册
if (phase < 0)
break;
// finish registration whenever parent registration
// succeeded, even when racing with termination,
// since these are part of the same "transaction".
//由于在同一个事务里,即使phaser已终止,也会完成注册
while (!UNSAFE.compareAndSwapLong
(this, stateOffset, s,
((long)phase << PHASE_SHIFT) | adjust)) {//更新phase
s = state;
phase = (int)(root.state >>> PHASE_SHIFT);
// assert (int)s == EMPTY;
}
break;
}
}
}
}
return phase;
}

说明:register方法为phaser添加一个新的party,如果onAdvance正在运行,那么这个方法会等待它运行结束再返回结果。如果当前phaser有父节点,并且当前phaser上没有已注册的party,那么就会交给父节点注册。

register和bulkRegister都由doRegister实现,大概流程如下:

  • 如果当前操作不是首次注册,那么直接在当前phaser上更新注册parties数

  • 如果是首次注册,并且当前phaser没有父节点,说明是root节点注册,直接更新phase

  • 如果当前操作是首次注册,并且当前phaser由父节点,则注册操作交由父节点,并更新当前phaser的phase

  • 上面说过,子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。非首次注册时,如果Phaser有父节点,则调用reconcileState()方法解决root节点的phase延迟传递问题, 源码如下:

    • private long reconcileState() {
          final Phaser root = this.root;
          long s = state;
          if (root != this) {
              int phase, p;
              // CAS to root phase with current parties, tripping unarrived
              while ((phase = (int)(root.state >>> PHASE_SHIFT)) !=
                     (int)(s >>> PHASE_SHIFT) &&
                     !UNSAFE.compareAndSwapLong
                     (this, stateOffset, s,
                      s = (((long)phase << PHASE_SHIFT) |
                           ((phase < 0) ? (s & COUNTS_MASK) :
                            (((p = (int)s >>> PARTIES_SHIFT) == 0) ? EMPTY :
                             ((s & PARTIES_MASK) | p))))))
                  s = state;
          }
          return s;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57

      - 当root节点的phase已经advance到下一代,但是子节点phaser还没有,这种情况下它们必须通过更新未到达parties数完成它们自己的advance操作(如果parties为0,重置为EMPTY状态)。

      - 回到register方法的第一步,如果当前未到达数为0,说明上一代phase正在进行到达操作,此时调用internalAwaitAdvance()方法等待其他任务完成到达操作,源码如下:

      - ```java
      //阻塞等待phase到下一代
      private int internalAwaitAdvance(int phase, QNode node) {
      // assert root == this;
      releaseWaiters(phase-1); // ensure old queue clean
      boolean queued = false; // true when node is enqueued
      int lastUnarrived = 0; // to increase spins upon change
      int spins = SPINS_PER_ARRIVAL;
      long s;
      int p;
      while ((p = (int)((s = state) >>> PHASE_SHIFT)) == phase) {
      if (node == null) { // spinning in noninterruptible mode
      int unarrived = (int)s & UNARRIVED_MASK;//未到达数
      if (unarrived != lastUnarrived &&
      (lastUnarrived = unarrived) < NCPU)
      spins += SPINS_PER_ARRIVAL;
      boolean interrupted = Thread.interrupted();
      if (interrupted || --spins < 0) { // need node to record intr
      //使用node记录中断状态
      node = new QNode(this, phase, false, false, 0L);
      node.wasInterrupted = interrupted;
      }
      }
      else if (node.isReleasable()) // done or aborted
      break;
      else if (!queued) { // push onto queue
      AtomicReference<QNode> head = (phase & 1) == 0 ? evenQ : oddQ;
      QNode q = node.next = head.get();
      if ((q == null || q.phase == phase) &&
      (int)(state >>> PHASE_SHIFT) == phase) // avoid stale enq
      queued = head.compareAndSet(q, node);
      }
      else {
      try {
      ForkJoinPool.managedBlock(node);//阻塞给定node
      } catch (InterruptedException ie) {
      node.wasInterrupted = true;
      }
      }
      }

      if (node != null) {
      if (node.thread != null)
      node.thread = null; // avoid need for unpark()
      if (node.wasInterrupted && !node.interruptible)
      Thread.currentThread().interrupt();
      if (p == phase && (p = (int)(state >>> PHASE_SHIFT)) == phase)
      return abortWait(phase); // possibly clean up on abort
      }
      releaseWaiters(phase);
      return p;
      }
  • 简单介绍下第二个参数node,如果不为空,则说明等待线程需要追踪中断状态或超时状态。以doRegister中的调用为例,不考虑线程争用,internalAwaitAdvance大概流程如下:

    • 首先调用releaseWaiters唤醒上一代所有等待线程,确保旧队列中没有遗留的等待线程。
    • 循环SPINS_PER_ARRIVAL指定的次数或者当前线程被中断,创建node记录等待线程及相关信息。
    • 继续循环调用ForkJoinPool.managedBlock运行被阻塞的任务
    • 继续循环,阻塞任务运行成功被释放,跳出循环
    • 最后唤醒当前phase的线程
4、方法——arrive()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//使当前线程到达phaser,不等待其他任务到达。返回arrival phase number
public int arrive() {
return doArrive(ONE_ARRIVAL);
}

private int doArrive(int adjust) {
final Phaser root = this.root;
for (;;) {
long s = (root == this) ? state : reconcileState();
int phase = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
int counts = (int)s;
//获取未到达数
int unarrived = (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);
if (unarrived <= 0)
throw new IllegalStateException(badArrive(s));
if (UNSAFE.compareAndSwapLong(this, stateOffset, s, s-=adjust)) {//更新state
if (unarrived == 1) {//当前为最后一个未到达的任务
long n = s & PARTIES_MASK; // base of next state
int nextUnarrived = (int)n >>> PARTIES_SHIFT;
if (root == this) {
if (onAdvance(phase, nextUnarrived))//检查是否需要终止phaser
n |= TERMINATION_BIT;
else if (nextUnarrived == 0)
n |= EMPTY;
else
n |= nextUnarrived;
int nextPhase = (phase + 1) & MAX_PHASE;
n |= (long)nextPhase << PHASE_SHIFT;
UNSAFE.compareAndSwapLong(this, stateOffset, s, n);
releaseWaiters(phase);//释放等待phase的线程
}
//分层结构,使用父节点管理arrive
else if (nextUnarrived == 0) { //propagate deregistration
phase = parent.doArrive(ONE_DEREGISTER);
UNSAFE.compareAndSwapLong(this, stateOffset,
s, s | EMPTY);
}
else
phase = parent.doArrive(ONE_ARRIVAL);
}
return phase;
}
}
}

说明:arrive方法手动调整到达数,使当前线程到达phaser。arrive和arriveAndDeregister都调用了doArrive实现,大概流程如下:

  • 首先更新state(state - adjust);
  • 如果当前不是最后一个未到达的任务,直接返回phase
  • 如果当前是最后一个未到达的任务:
    • 如果当前是root节点,判断是否需要终止phaser,CAS更新phase,最后释放等待的线程;
    • 如果是分层结构,并且已经没有下一代未到达的parties,则交由父节点处理doArrive逻辑,然后更新state为EMPTY。
5、方法——arriveAndAwaitAdvance()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public int arriveAndAwaitAdvance() {
// Specialization of doArrive+awaitAdvance eliminating some reads/paths
final Phaser root = this.root;
for (;;) {
long s = (root == this) ? state : reconcileState();
int phase = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
int counts = (int)s;
int unarrived = (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);//获取未到达数
if (unarrived <= 0)
throw new IllegalStateException(badArrive(s));
if (UNSAFE.compareAndSwapLong(this, stateOffset, s,
s -= ONE_ARRIVAL)) {//更新state
if (unarrived > 1)
return root.internalAwaitAdvance(phase, null);//阻塞等待其他任务
if (root != this)
return parent.arriveAndAwaitAdvance();//子Phaser交给父节点处理
long n = s & PARTIES_MASK; // base of next state
int nextUnarrived = (int)n >>> PARTIES_SHIFT;
if (onAdvance(phase, nextUnarrived))//全部到达,检查是否可销毁
n |= TERMINATION_BIT;
else if (nextUnarrived == 0)
n |= EMPTY;
else
n |= nextUnarrived;
int nextPhase = (phase + 1) & MAX_PHASE;//计算下一代phase
n |= (long)nextPhase << PHASE_SHIFT;
if (!UNSAFE.compareAndSwapLong(this, stateOffset, s, n))//更新state
return (int)(state >>> PHASE_SHIFT); // terminated
releaseWaiters(phase);//释放等待phase的线程
return nextPhase;
}
}
}

说明:使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。效果类似于CyclicBarrier.await。大概流程如下:

  • 更新state(state - 1);
  • 如果未到达数大于1,调用internalAwaitAdvance阻塞等待其他任务到达,返回当前phase
  • 如果为分层结构,则交由父节点处理arriveAndAwaitAdvance逻辑
  • 如果未到达数<=1,判断phaser终止状态,CAS更新phase到下一代,最后释放等待当前phase的线程,并返回下一代phase。
6、方法——awaitAdvance(int phase)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public int awaitAdvance(int phase) {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase)
return root.internalAwaitAdvance(phase, null);
return p;
}
//响应中断版awaitAdvance
public int awaitAdvanceInterruptibly(int phase)
throws InterruptedException {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase) {
QNode node = new QNode(this, phase, true, false, 0L);
p = root.internalAwaitAdvance(phase, node);
if (node.wasInterrupted)
throw new InterruptedException();
}
return p;
}

说明:awaitAdvance用于阻塞等待线程到达,直到phase前进到下一代,返回下一代的phase number。方法很简单,不多赘述。awaitAdvanceInterruptibly方法是响应中断版的awaitAdvance,不同之处在于,调用阻塞时会记录线程的中断状态。

5、Exchanger(交换器)

Exchanger是用于线程协作的工具类,主要用于两个线程之间的数据交换

1、BAT大厂的面试问题
  • Exchanger主要解决什么问题?
  • 对比SynchronousQueue,为什么说Exchanger可被视为 SynchronousQueue 的双向形式?
  • Exchanger在不同的JDK版本中实现有什么差别?
  • Exchanger实现机制?
  • Exchanger已经有了slot单节点,为什么会加入arena node数组?什么时候会用到数组?
  • arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
  • 什么是伪共享,Exchanger中如何体现的?
  • Exchanger实现举例
2、Exchanger简介

Exchanger用于进行两个线程之间的数据交换。它提供一个==同步点==,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。

3、Exchanger实现机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (;;) {
if (slot is empty) { // offer
// slot为空时,将item 设置到Node 中
place item in a Node;
if (can CAS slot from empty to node) {
// 当将node通过CAS交换到slot中时,挂起线程等待被唤醒
wait for release;
// 被唤醒后返回node中匹配到的item
return matching item in node;
}
} else if (can CAS slot from node to empty) { // release
// 将slot设置为空
// 获取node中的item,将需要交换的数据设置到匹配的item
get the item in node;
set matching item in node;
// 唤醒等待的线程
release waiting thread;
}
// else retry on CAS failure
}

比如有2条线程A和B,A线程交换数据时,发现slot为空,则将需要交换的数据放在slot中等待其它线程进来交换数据,等线程B进来,读取A设置的数据,然后设置线程B需要交换的数据,然后唤醒A线程,原理就是这么简单。但是当多个线程之间进行交换数据时就会出现问题,所以Exchanger加入了arena数组

4、Exchanger源码解析
1、内部类——Participant
1
2
3
static final class Participant extends ThreadLocal<Node> {
public Node initialValue() { return new Node(); }
}

Participant的作用是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,说明每个线程具有不同的状态。

2、内部类——Node
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@sun.misc.Contended static final class Node {
// arena的下标,多个槽位的时候利用
int index;
// 上一次记录的Exchanger.bound
int bound;
// 在当前bound下CAS失败的次数;
int collides;
// 用于自旋;
int hash;
// 这个线程的当前项,也就是需要交换的数据;
Object item;
//做releasing操作的线程传递的项;
volatile Object match;
//挂起时设置线程值,其他情况下为null;
volatile Thread parked;
}

在Node定义中有两个变量值得思考:bound以及collides。前面提到了数组area是为了避免竞争而产生的,如果系统不存在竞争问题,那么完全没有必要开辟一个高效的arena来徒增系统的复杂性

  1. 首先通过单个slot的exchanger来交换数据,当探测到竞争时将安排不同的位置的slot来保存线程Node,并且可以确保没有slot会在同一个缓存行上。
  2. 如何来判断会有竞争呢?
    • CAS替换slot失败,如果失败,则通过记录冲突次数来扩展arena的尺寸,我们在记录冲突的过程中会跟踪“bound”的值,以及会重新计算在bound的值被改变时的冲突次数。
3、核心属性
1
2
3
private final Participant participant;
private volatile Node[] arena;
private volatile Node slot;
  • **为什么会有 arena数组槽**?

    • slot为单个槽,arena为数组槽,他们都是Node类型。
    • 在这里可能会感觉到疑惑,slot作为Exchanger交换数据的场景,应该只需要一个就可以了啊?为何还多了一个Participant和数组类型的arena呢?
    • 一个slot交换场所原则上来说应该是可以的,但实际情况却不是如此,多个参与者使用同一个交换场所时,会存在严重==伸缩性问题==。既然单个交换场所存在问题,那么我们就安排多个,也就是数组arena。
    • 通过数组arena来安排不同的线程使用不同的slot来降低竞争问题,并且可以保证最终一定会成对交换数据。但是**Exchanger不是一来就会生成arena数组来降低竞争,==只有当产生竞争是才会生成arena数组==**。
  • 那么怎么将Node与当前线程绑定呢?

    • Participant,Participant 的作用就是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,同时在Node节点中记录在arena中的下标index。
4、构造函数
1
2
3
4
5
6
/**
* Creates a new Exchanger.
*/
public Exchanger() {
participant = new Participant();
}

初始化participant对象。

5、核心方法——exchange(V x)

等待另一个线程到达此交换点(除非当前线程被中断),然后将给定的对象传送给该线程,并接收该线程的对象。

1
2
3
4
5
6
7
8
9
10
11
12
public V exchange(V x) throws InterruptedException {
Object v;
// 当参数为null时需要将item设置为空的对象
Object item = (x == null) ? NULL_ITEM : x; // translate null args
// 注意到这里的这个表达式是整个方法的核心
if ((arena != null ||
(v = slotExchange(item, false, 0 L)) == null) &&
((Thread.interrupted() || // disambiguates null return
(v = arenaExchange(item, false, 0 L)) == null)))
throw new InterruptedException();
return (v == NULL_ITEM) ? null : (V) v;
}

这个方法比较好理解:

  • arena为数组槽,如果为null,则执行slotExchange()方法,
  • 否则判断线程是否中断,如果中断值抛出InterruptedException异常,
  • 没有中断则执行arenaExchange()方法。

整套逻辑就是:如果slotExchange(Object item, boolean timed, long ns)方法执行失败了就执行arenaExchange(Object item, boolean timed, long ns)方法,最后返回结果V。

NULL_ITEM 为一个空节点,其实就是一个Object对象而已,slotExchange()为单个slot交换

6、slotExchange(Object item, boolean timed, long ns)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
private final Object slotExchange(Object item, boolean timed, long ns) {
// 获取当前线程node对象
Node p = participant.get();
// 当前线程
Thread t = Thread.currentThread();
// 若果线程被中断,就直接返回null
if (t.isInterrupted()) // preserve interrupt status so caller can recheck
return null;
// 自旋
for (Node q;;) {
// 将slot值赋给q
if ((q = slot) != null) {
// slot 不为null,即表示已有线程已经把需要交换的数据设置在slot中了
// 通过CAS将slot设置成null
if (U.compareAndSwapObject(this, SLOT, q, null)) {
// CAS操作成功后,将slot中的item赋值给对象v,以便返回。
// 这里也是就读取之前线程要交换的数据
Object v = q.item;
// 将当前线程需要交给的数据设置在q中的match
q.match = item;
// 获取被挂起的线程
Thread w = q.parked;
if (w != null)
// 如果线程不为null,唤醒它
U.unpark(w);
// 返回其他线程给的V
return v;
}
// create arena on contention, but continue until slot null
// CAS 操作失败,表示有其它线程竞争,在此线程之前将数据已取走
// NCPU:CPU的核数
// bound == 0 表示arena数组未初始化过,CAS操作bound将其增加SEQ
if (NCPU > 1 && bound == 0 &&
U.compareAndSwapInt(this, BOUND, 0, SEQ))
// 初始化arena数组
arena = new Node[(FULL + 2) << ASHIFT];
}
// 上面分析过,只有当arena不为空才会执行slotExchange方法的
// 所以表示刚好已有其它线程加入进来将arena初始化
else if (arena != null)
// 这里就需要去执行arenaExchange
return null; // caller must reroute to arenaExchange
else {
// 这里表示当前线程是以第一个线程进来交换数据
// 或者表示之前的数据交换已进行完毕,这里可以看作是第一个线程
// 将需要交换的数据先存放在当前线程变量p中
p.item = item;
// 将需要交换的数据通过CAS设置到交换区slot
if (U.compareAndSwapObject(this, SLOT, null, p))
// 交换成功后跳出自旋
break;
// CAS操作失败,表示有其它线程刚好先于当前线程将数据设置到交换区slot
// 将当前线程变量中的item设置为null,然后自旋获取其它线程存放在交换区slot的数据
p.item = null;
}
}

// await release
// 执行到这里表示当前线程已将需要的交换的数据放置于交换区slot中了,
// 等待其它线程交换数据然后唤醒当前线程
int h = p.hash;
long end = timed ? System.nanoTime() + ns : 0 L;
// 自旋次数
int spins = (NCPU > 1) ? SPINS : 1;
Object v;
// 自旋等待直到p.match不为null,也就是说等待其它线程将需要交换的数据放置于交换区slot
while ((v = p.match) == null) {
// 下面的逻辑主要是自旋等待,直到spins递减到0为止
if (spins > 0) {
h ^= h << 1;
h ^= h >>> 3;
h ^= h << 10;
if (h == 0)
h = SPINS | (int) t.getId();
else if (h < 0 && (--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield();
} else if (slot != p)
spins = SPINS;
// 此处表示未设置超时或者时间未超时
else if (!t.isInterrupted() && arena == null &&
(!timed || (ns = end - System.nanoTime()) > 0 L)) {
// 设置线程t被当前对象阻塞
U.putObject(t, BLOCKER, this);
// 给p挂机线程的值赋值
p.parked = t;
if (slot == p)
// 如果slot还没有被置为null,也就表示暂未有线程过来交换数据,需要将当前线程挂起
U.park(false, ns);
// 线程被唤醒,将被挂起的线程设置为null
p.parked = null;
// 设置线程t未被任何对象阻塞
U.putObject(t, BLOCKER, null);
// 不是以上条件时(可能是arena已不为null或者超时)
} else if (U.compareAndSwapObject(this, SLOT, p, null)) {
// arena不为null则v为null,其它为超时则v为超市对象TIMED_OUT,并且跳出循环
v = timed && ns <= 0 L && !t.isInterrupted() ? TIMED_OUT : null;
break;
}
}
// 取走match值,并将p中的match置为null
U.putOrderedObject(p, MATCH, null);
// 设置item为null
p.item = null;
p.hash = h;
// 返回交换值
return v;
}

程序首先通过participant获取当前线程节点Node。检测是否中断,如果中断return null,等待后续抛出InterruptedException异常。

  • 如果slot不为null,则进行slot消除,成功直接返回数据V,否则失败,则创建arena消除数组。
  • 如果slot为null,但arena不为null,则返回null,进入arenaExchange逻辑。
  • 如果slot为null,且arena也为null,则尝试占领该slot,失败重试,成功则跳出循环进入spin+block(自旋+阻塞)模式。

在自旋+阻塞模式中,首先取得结束时间和自旋次数

  • 如果match(做releasing操作的线程传递的项)为null,其首先尝试spins+随机次自旋(改自旋使用当前节点中的hash,并改变之)和退让。
  • 当自旋数为0后,假如slot发生了改变(slot != p)则重置自旋数并重试。
  • 否则
    • 假如:当前未中断&arena为null&(当前不是限时版本或者限时版本+当前时间未结束):阻塞或者限时阻塞。
    • 假如:当前中断或者arena不为null或者当前为限时版本+时间已经结束:
      • 不限时版本:置v为null;
      • 限时版本:如果时间结束以及未中断则TIMED_OUT;
  • 否则给出null(原因是探测到arena非空或者当前线程中断)。
  • match不为空时跳出循环。
7、arenaExchange(Object item, boolean timed, long ns)

此方法被执行时表示多个线程进入交换区交换数据,arena数组已被初始化,此方法中的一些处理方式和slotExchange比较类似,它是通过遍历arena数组找到需要交换的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// timed 为true表示设置了超时时间,ns为>0的值,反之没有设置超时时间
private final Object arenaExchange(Object item, boolean timed, long ns) {
Node[] a = arena;
// 获取当前线程中的存放的node
Node p = participant.get();
//index初始值0
for (int i = p.index;;) { // access slot at i
// 遍历,如果在数组中找到数据则直接交换并唤醒线程,如未找到则将需要交换给其它线程的数据放置于数组中
int b, m, c;
long j; // j is raw array offset
// 其实这里就是向右遍历数组,只是用到了元素在内存偏移的偏移量
// q实际为arena数组偏移(i + 1) * 128个地址位上的node
Node q = (Node) U.getObjectVolatile(a, j = (i << ASHIFT) + ABASE);
// 如果q不为null,并且CAS操作成功,将下标j的元素置为null
if (q != null && U.compareAndSwapObject(a, j, q, null)) {
// 表示当前线程已发现有交换的数据,然后获取数据,唤醒等待的线程
Object v = q.item; // release
q.match = item;
Thread w = q.parked;
if (w != null)
U.unpark(w);
return v;
// q 为null 并且 i 未超过数组边界
} else if (i <= (m = (b = bound) & MMASK) && q == null) {
// 将需要给其它线程的item赋予给p中的item
p.item = item; // offer
if (U.compareAndSwapObject(a, j, null, p)) {
// 交换成功
long end = (timed && m == 0) ? System.nanoTime() + ns : 0 L;
Thread t = Thread.currentThread(); // wait
// 自旋直到有其它线程进入,遍历到该元素并与其交换,同时当前线程被唤醒
for (int h = p.hash, spins = SPINS;;) {
Object v = p.match;
if (v != null) {
// 其它线程设置的需要交换的数据match不为null
// 将match设置null,item设置为null
U.putOrderedObject(p, MATCH, null);
p.item = null; // clear for next use
p.hash = h;
return v;
} else if (spins > 0) {
h ^= h << 1;
h ^= h >>> 3;
h ^= h << 10; // xorshift
if (h == 0) // initialize hash
h = SPINS | (int) t.getId();
else if (h < 0 && // approx 50% true
(--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield(); // two yields per wait
} else if (U.getObjectVolatile(a, j) != p)
// 和slotExchange方法中的类似,arena数组中的数据已被CAS设置
// match值还未设置,让其再自旋等待match被设置
spins = SPINS; // releaser hasn't set match yet
else if (!t.isInterrupted() && m == 0 &&
(!timed ||
(ns = end - System.nanoTime()) > 0 L)) {
// 设置线程t被当前对象阻塞
U.putObject(t, BLOCKER, this); // emulate LockSupport
// 线程t赋值
p.parked = t; // minimize window
if (U.getObjectVolatile(a, j) == p)
// 数组中对象还相等,表示线程还未被唤醒,唤醒线程
U.park(false, ns);
p.parked = null;
// 设置线程t未被任何对象阻塞
U.putObject(t, BLOCKER, null);
} else if (U.getObjectVolatile(a, j) == p &&
U.compareAndSwapObject(a, j, p, null)) {
// 这里给bound增加加一个SEQ
if (m != 0) // try to shrink
U.compareAndSwapInt(this, BOUND, b, b + SEQ - 1);
p.item = null;
p.hash = h;
i = p.index >>>= 1; // descend
if (Thread.interrupted())
return null;
if (timed && m == 0 && ns <= 0 L)
return TIMED_OUT;
break; // expired; restart
}
}
} else
// 交换失败,表示有其它线程更改了arena数组中下标i的元素
p.item = null; // clear offer
} else {
// 此时表示下标不在bound & MMASK或q不为null但CAS操作失败
// 需要更新bound变化后的值
if (p.bound != b) { // stale; reset
p.bound = b;
p.collides = 0;
// 反向遍历
i = (i != m || m == 0) ? m : m - 1;
} else if ((c = p.collides) < m || m == FULL ||
!U.compareAndSwapInt(this, BOUND, b, b + SEQ + 1)) {
// 记录CAS失败的次数
p.collides = c + 1;
// 循环遍历
i = (i == 0) ? m : i - 1; // cyclically traverse
} else
// 此时表示bound值增加了SEQ+1
i = m + 1; // grow
// 设置下标
p.index = i;
}
}
}

首先通过participant取得当前节点Node,然后根据当前节点Node的index去取arena中相对应的节点node。

5、前面提到过arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arena = new Node[(FULL + 2) << ASHIFT];
// 这个arena到底有多大呢? 我们先看FULL 和ASHIFT的定义:
static final int FULL = (NCPU >= (MMASK << 1)) ? MMASK : NCPU >>> 1;
private static final int ASHIFT = 7;

private static final int NCPU = Runtime.getRuntime().availableProcessors();
private static final int MMASK = 0xff; // 255
// 假如我的机器NCPU = 8 ,则得到的是768大小的arena数组。然后通过以下代码取得在arena中的节点:

Node q = (Node)U.getObjectVolatile(a, j = (i << ASHIFT) + ABASE);
// 它仍然是通过右移ASHIFT位来取得Node的,ABASE定义如下:

Class<?> ak = Node[].class;
ABASE = U.arrayBaseOffset(ak) + (1 << ASHIFT);
// U.arrayBaseOffset获取对象头长度,数组元素的大小可以通过unsafe.arrayIndexScale(T[].class) 方法获取到。这也就是说要访问类型为T的第N个元素的话,你的偏移量offset应该是arrayOffset+N*arrayScale。也就是说BASE = arrayOffset+ 128 。
6、用@sun.misc.Contended来规避伪共享?

伪共享说明假设一个类的两个相互独立的属性a和b在内存地址上是连续的(比如FIFO队列的头尾指针),那么它们通常会被加载到相同的cpu cache line里面。并发情况下,如果一个线程修改了a,会导致整个cache line失效(包括b),这时另一个线程来读b,就需要从内存里再次加载了,这种多线程频繁修改ab的情况下,虽然a和b看似独立,但它们会互相干扰,非常影响性能。(在原子累加器篇也有伪共享问题的阐述)

我们再看Node节点的定义,在Java 8 中我们是可以利用sun.misc.Contended来规避伪共享的。所以说通过 << ASHIFT方式加上sun.misc.Contended,所以使得任意两个可用Node不会再同一个缓存行中。

1
2
3
@sun.misc.Contended static final class Node{
....
}

我们再次回到arenaExchange()。取得arena中的node节点后,如果定位的节点q 不为空,且CAS操作成功,则交换数据,返回交换的数据,唤醒等待的线程。

  • 如果q等于null且下标在bound & MMASK范围之内,则尝试占领该位置,如果成功,则采用自旋 + 阻塞的方式进行等待交换数据。
  • 如果下标不在bound & MMASK范围之内获取由于q不为null但是竞争失败的时候:消除p。加入bound 不等于当前节点的bond(b != p.bound),则更新p.bound = b,collides = 0 ,i = m或者m - 1。如果冲突的次数不到m 获取m 已经为最大值或者修改当前bound的值失败,则通过增加一次collides以及循环递减下标i的值;否则更新当前bound的值成功:我们令i为m+1即为此时最大的下标。最后更新当前index的值。
7、更深入理解
1、SynchronousQueue对比?

Exchanger是一种线程间安全交换数据的机制。可以和之前分析过的SynchronousQueue对比一下:

  • 线程A通过SynchronousQueue将数据a交给线程B;
  • 线程A通过Exchanger和线程B交换数据,线程A把数据a交给线程B,同时线程B把数据b交给线程A。

可见,SynchronousQueue是交给一个数据,Exchanger是交换两个数据。

2、不同JDK实现有何差别?
  • 在JDK5中Exchanger被设计成一个容量为1的容器,存放一个等待线程,直到有另外线程到来就会发生数据交换,然后清空容器,等到下一个到来的线程。
  • 从JDK6开始,Exchanger用了类似ConcurrentMap的分段思想,提供了多个slot,增加了并发执行时的吞吐量
8、Exchanger示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class Test {
static class Producer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Producer(String name, Exchanger<Integer> exchanger) {
super("Producer-" + name);
this.exchanger = exchanger;
}

@Override
public void run() {
for (int i=1; i<5; i++) {
try {
TimeUnit.SECONDS.sleep(1);
data = i;
System.out.println(getName()+" 交换前:" + data);
data = exchanger.exchange(data);
System.out.println(getName()+" 交换后:" + data);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

static class Consumer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Consumer(String name, Exchanger<Integer> exchanger) {
super("Consumer-" + name);
this.exchanger = exchanger;
}

@Override
public void run() {
while (true) {
data = 0;
System.out.println(getName()+" 交换前:" + data);
try {
TimeUnit.SECONDS.sleep(1);
data = exchanger.exchange(data);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName()+" 交换后:" + data);
}
}
}

public static void main(String[] args) throws InterruptedException {
Exchanger<Integer> exchanger = new Exchanger<Integer>();
new Producer("", exchanger).start();
new Consumer("", exchanger).start();
TimeUnit.SECONDS.sleep(7);
System.exit(-1);
}
}

可以看到,其结果可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Consumer- 交换前:0
Producer- 交换前:1
Consumer- 交换后:1
Consumer- 交换前:0
Producer- 交换后:0
Producer- 交换前:2
Producer- 交换后:0
Consumer- 交换后:2
Consumer- 交换前:0
Producer- 交换前:3
Producer- 交换后:0
Consumer- 交换后:3
Consumer- 交换前:0
Producer- 交换前:4
Producer- 交换后:0
Consumer- 交换后:4
Consumer- 交换前:0

16、ThreadPool线程池

1、线程池简介

线程池(英语:thread pool):一种线程使用模式。线程过多会带来调度开销, 进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。

2、线程池的优势

线程池做的工作只要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量, 超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。

3、线程池的主要特点

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的销耗。
  • 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会销耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

Java 中的线程池是通过 Executor 框架实现的,该框架中用到了 ExecutorExecutorsExecutorServiceThreadPoolExecutor 这几个类:

image-20210728133227349

4、线程池参数说明

  • corePoolSize:线程池的核心线程数
  • maximumPoolSize:能容纳的最大线程数
  • keepAliveTime:空闲线程存活时间
  • unit:存活的时间单位
  • workQueue:存放提交但未执行任务的队列
  • threadFactory:创建线程的工厂类
  • handler:等待队列满后的拒绝策略

image-20210728134755032

5、拒绝策略(重点)

线程池中,有三个重要的参数,决定影响了拒绝策略:corePoolSize - 核心线程数,也即最小的线程数。workQueue - 阻塞队列 。 maximumPoolSize -最大线程数。

当提交任务数大于 corePoolSize 的时候,会优先将任务放到 workQueue 阻塞队列中。当阻塞队列饱和后,会扩充线程池中线程数,直到达到maximumPoolSize 最大线程数配置。此时,再多余的任务,则会触发线程池的拒绝策略了。

总结起来,也就是一句话,当提交的任务数大于(workQueue.size() + maximumPoolSize ),就会触发线程池的拒绝策略。

四种拒绝策略:

  • CallerRunsPolicy:当触发拒绝策略,只要线程池没有关闭的话,则使用调用线程直接运行任务。一般并发比较小,性能要求不高,不允许失败。但是,由于调用者自己运行任务,如果任务提交速度过快,可能导致程序阻塞,性能效率上必然的损失较大;
  • AbortPolicy:丢弃任务,并抛出拒绝执行 RejectedExecutionException 异常信息。线程池默认的拒绝策略。必须处理好抛出的异常,否则会打断当前的执行流程,影响后续的任务执行。
  • DiscardPolicy:直接丢弃,其他啥都没有;
  • DiscardOldestPolicy:当触发拒绝策略,只要线程池没有关闭的话,丢弃阻塞队列 workQueue 中最老的一个任务,并将新任务加入

除了线程池给的四种拒绝策略的实现,其他著名框架也提供了拒绝策略实现:

  • Dubbo 的实现:在抛出 RejectedExecutionException 异常之前会记录日志,并 dump 线程栈信息方便定位问题
  • Netty 的实现:创建一个新线程来执行任务
  • ActiveMQ 的实现:带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略
  • PinPoint 的实现:它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略

6、线程池的种类与创建

  • newCachedThreadPool——线程池根据需求创建线程,可扩容,遇强则强

    • 作用:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

    • 特点:

      • 线程池中数量没有固定,可达到最大值(Interger. MAX_VALUE)
      • 线程池中的线程可进行缓存重复利用和回收(回收默认时间为 1 分钟)
      • 当线程池中,没有可用线程,会重新创建一个线程
    • 创建方式:

      • /**
          *    可缓存线程池
          * @return
          */
        public static ExecutorService newCachedThreadPool(){
            /**
            *    corePoolSize 线程池的核心线程数
            *    maximumPoolSize 能容纳的最大线程数
            *    keepAliveTime 空闲线程存活时间
            *    unit 存活的时间单位
            *    workQueue 存放提交但未执行任务的队列
            *    threadFactory 创建线程的工厂类:可以省略
            *    handler 等待队列满后的拒绝策略:可以省略
            */ 
            return new ThreadPoolExecutor(0, 
                   Integer.MAX_VALUE, 
                   60L,                        
                   TimeUnit.SECONDS,                        
                   new SynchronousQueue<>(),                        
                   Executors.defaultThreadFactory(),                       
                   new ThreadPoolExecutor.AbortPolicy());
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38

        - 场景:适用于创建一个可无限扩大的线程池,服务器负载压力较轻,执行时间较短,任务多的场景

        - newFixedThreadPool——一池N线程

        - 作用:创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。在任意点,在大多数线程会处于处理任务的活动状态。如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。

        - 特点:

        - 线程池中的线程处于一定的量,可以很好的控制线程的并发量
        - 线程可以重复被使用,在显示关闭之前,都将一直存在
        - 超出一定量的线程被提交时候需在队列中等待

        - 创建方式:

        - ```java
        /**
        * 固定长度线程池
        * @return
        */
        public static ExecutorService newCachedThreadPool(){
        /**
        * corePoolSize 线程池的核心线程数
        * maximumPoolSize 能容纳的最大线程数
        * keepAliveTime 空闲线程存活时间
        * unit 存活的时间单位
        * workQueue 存放提交但未执行任务的队列
        * threadFactory 创建线程的工厂类:可以省略
        * handler 等待队列满后的拒绝策略:可以省略
        */
        return new ThreadPoolExecutor(10,
        Integer.MAX_VALUE,
        0L,
        TimeUnit.SECONDS,
        new SynchronousQueue<>(),
        Executors.defaultThreadFactory(),
        new ThreadPoolExecutor.AbortPolicy());
        }
    • 场景:适用于可以预测线程数量的业务中,或者服务器负载较重,对线程数有严格限制的场景

  • newSingleThreadExecutor——一个任务一个任务执行,一池一线程

    • 作用:创建一个使用单个 worker 线程的 Executor,以无界队列方式来运行该线程。(注意,如果因为在关闭前的执行期间出现失败而终止了此单个线程, 那么如果需要,一个新线程将代替它执行后续的任务)。可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。与其他等效的newFixedThreadPool 不同,可保证无需重新配置此方法所返回的执行程序即可使用其他的线程。

    • 特点:线程池中最多执行 1 个线程,之后提交的线程活动将会排在队列中以此执行

    • 创建方式:

      • /**
          *    单一线程池
          * @return
          */
        public static ExecutorService newCachedThreadPool(){
            /**
            *    corePoolSize 线程池的核心线程数
            *    maximumPoolSize 能容纳的最大线程数
            *    keepAliveTime 空闲线程存活时间
            *    unit 存活的时间单位
            *    workQueue 存放提交但未执行任务的队列
            *    threadFactory 创建线程的工厂类:可以省略
            *    handler 等待队列满后的拒绝策略:可以省略
            */ 
            return new ThreadPoolExecutor(1, 
                   1, 
                   0L,                        
                   TimeUnit.SECONDS,                        
                   new SynchronousQueue<>(),                        
                   Executors.defaultThreadFactory(),                       
                   new ThreadPoolExecutor.AbortPolicy());
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19

        - 场景:适用于需要保证顺序执行各个任务,并且在任意时间点,不会同时有多个线程的场景

        - newScheduleThreadPool(了解)——定时以及周期性执行任务线程池

        - 作用:线程池支持定时以及周期性执行任务,创建一个 corePoolSize 为传入参数,最大线程数为整形的最大数的线程池

        - 特点:

        - 线程池中具有指定数量的线程,即便是空线程也将保留
        - 可定时或者延迟执行线程活动

        - 创建方式:

        - ```java
        public static ScheduledExecutorService newScheduledThreadPool
        (int corePoolSize, ThreadFactory threadFactory) {
        return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
        }
    • 场景:适用于需要多个后台线程执行周期任务的场景

  • newWorkStealingPool——多个任务队列的线程池

    • dk1.8 提供的线程池,底层使用的是 ForkJoinPool 实现,创建一个拥有多个任务队列的线程池,可以减少连接数,创建当前可用 cpu 核数的线程来并行执行任务

    • 创建方式:

      • public static ExecutorService newWorkStealingPool(int parallelism) {
            /**
            *    parallelism:并行级别,通常默认为 JVM 可用的处理器个数
            *    factory:用于创建 ForkJoinPool 中使用的线程。
            *    handler:用于处理工作线程未处理的异常,默认为 null
            *    asyncMode:用于控制 WorkQueue 的工作模式:队列---反队列
            */ 
            return new ForkJoinPool(parallelism, 
                                    ForkJoinPool.defaultForkJoinWorkerThreadFactory, 
                                    null, 
                                    true);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        76
        77
        78
        79
        80
        81
        82
        83
        84
        85
        86
        87
        88
        89
        90
        91
        92
        93
        94
        95
        96
        97
        98
        99
        100
        101
        102
        103
        104
        105
        106
        107
        108
        109
        110
        111
        112
        113
        114
        115
        116
        117
        118
        119
        120
        121
        122
        123
        124
        125
        126
        127
        128
        129
        130
        131
        132
        133
        134
        135
        136
        137
        138
        139
        140
        141
        142
        143
        144
        145
        146
        147
        148
        149
        150
        151
        152
        153
        154
        155
        156
        157
        158
        159
        160
        161
        162
        163
        164
        165
        166
        167
        168
        169
        170
        171
        172
        173
        174
        175
        176
        177
        178
        179
        180
        181
        182
        183
        184
        185
        186
        187

        - 场景:适用于大耗时,可并行执行的场景



        #### 7、线程池底层的工作原理

        ![10-线程池底层工作流程](JUC/10-线程池底层工作流程.png)

        1. 在创建了线程池后,线程池中的线程数为零,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
        2. 当调用 execute()方法添加一个请求任务时,线程池会做出如下判断:
        1. 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
        2. 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入workQueue 队列;
        3. 如果这个时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务(救急);
        4. 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
        3. 当一个线程完成任务时,它会从队列中取下一个任务来执行
        4. 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:
        1. 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
        2. 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

        ![image-20210728141353731](JUC/image-20210728141353731.png)



        #### 8、线程池的注意事项

        1. **线程的创建不是在创建线程池的时候创建,而是在执行execute()方法的时候,线程才真正地开始创建**;
        2. 项目中创建多线程时,使用常见的三种线程池创建方式,`单一`、`可变`、`定长`。但是它们都有一定问题,原因是 `FixedThreadPool` 和 `SingleThreadExecutor` 底层都是用LinkedBlockingQueue 实现的,这个队列最大长度为 Integer.MAX_VALUE,**容易导致 OOM**。**所以实际生产一般自己通过 ThreadPoolExecutor 的 7 个参数,自定义线程池**。
        3. 创建线程池推荐适用 ThreadPoolExecutor 及其 7 个参数手动创建:
        - corePoolSize 线程池的核心线程数
        - maximumPoolSize 能容纳的最大线程数
        - keepAliveTime 空闲线程存活时间
        - unit 存活的时间单位
        - workQueue 存放提交但未执行任务的队列
        - threadFactory 创建线程的工厂类
        - handler 等待队列满后的拒绝策略
        4. 为什么不允许适用不允许 Executors.的方式手动创建线程池,如下图:
        - ![image-20210728141417446](JUC/image-20210728141417446.png)



        #### 9、自定义线程池

        ![image-20210810234602627](JUC/image-20210810234602627.png)

        注意:以下的==任务队列==和==拒绝策略的接口==其实不用我们编写,可以使用JUC为我们提供的BlockingQueue,而拒绝策略的话,直接使用lambda表达式实现JUC提供好的拒绝策略接口中的reject方法即可。

        ##### 1、步骤1:自定义任务队列

        ```java
        class BlockingQueue<T> {
        // 1. 任务队列
        private Deque<T> queue = new ArrayDeque<>();

        // 2. 锁
        private ReentrantLock lock = new ReentrantLock();

        // 3. 生产者条件变量
        private Condition fullWaitSet = lock.newCondition();

        // 4. 消费者条件变量
        private Condition emptyWaitSet = lock.newCondition();

        // 5. 容量
        private int capcity;

        public BlockingQueue(int capcity) {
        this.capcity = capcity;
        }

        // 带超时阻塞获取
        public T poll(long timeout, TimeUnit unit) {
        lock.lock();
        try {
        // 将 timeout 统一转换为 纳秒 (时间统一管理)
        long nanos = unit.toNanos(timeout);
        while (queue.isEmpty()) {
        try {
        // 返回值是剩余时间
        if (nanos <= 0) {
        return null;
        }
        // 返回值是 等待时间-执行时间
        nanos = emptyWaitSet.awaitNanos(nanos);
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        T t = queue.removeFirst();
        fullWaitSet.signal();
        return t;
        } finally {
        lock.unlock();
        }
        }

        // 阻塞获取
        public T take() {
        lock.lock();
        try {
        while (queue.isEmpty()) {
        try {
        emptyWaitSet.await();
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        T t = queue.removeFirst();
        fullWaitSet.signal();
        return t;
        } finally {
        lock.unlock();
        }
        }

        // 阻塞添加
        public void put(T task) {
        lock.lock();
        try {
        while (queue.size() == capcity) {
        try {
        log.debug("等待加入任务队列 {} ...", task);
        fullWaitSet.await();
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        } finally {
        lock.unlock();
        }
        }

        // 带超时时间阻塞添加
        public boolean offer(T task, long timeout, TimeUnit timeUnit) {
        lock.lock();
        try {
        long nanos = timeUnit.toNanos(timeout);
        while (queue.size() == capcity) {
        try {
        if(nanos <= 0) {
        return false;
        }
        log.debug("等待加入任务队列 {} ...", task);
        nanos = fullWaitSet.awaitNanos(nanos);
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        return true;
        } finally {
        lock.unlock();
        }
        }

        // 返回等待队列的长度
        public int size() {
        lock.lock();
        try {
        return queue.size();
        } finally {
        lock.unlock();
        }
        }

        // 尝试放入阻塞队列,若不能放进阻塞队列,执行拒绝策略
        public void tryPut(RejectPolicy<T> rejectPolicy, T task) {
        lock.lock();
        try {
        // 判断队列是否满
        if(queue.size() == capcity) {
        rejectPolicy.reject(this, task);
        } else { // 有空闲
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        }
        } finally {
        lock.unlock();
        }
        }
        }
2、步骤2:自定义拒绝策略接口
1
2
3
4
5
// 拒绝策略 由于只有一个方法,可以使用函数式接口(lambda表达式)
@FunctionalInterface
interface RejectPolicy<T> {
void reject(BlockingQueue<T> queue, T task);
}
3、步骤3:自定义线程池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class ThreadPool {
// 任务队列
private BlockingQueue<Runnable> taskQueue;

// 线程集合
// 这里将线程进行进一步的封装,封装成Worker对象,有更多的操作空间
private HashSet<Worker> workers = new HashSet<>();

// 核心线程数
private int coreSize;

// 获取任务时的超时时间
private long timeout;

// 超时时间的单位
private TimeUnit timeUnit;

// 拒绝策略
private RejectPolicy<Runnable> rejectPolicy;

// 执行任务
public void execute(Runnable task) {
// 当任务数没有超过 coreSize 时,直接交给 worker 对象执行
// 如果任务数超过 coreSize 时,执行拒绝策略
synchronized (workers) {
if(workers.size() < coreSize) {
Worker worker = new Worker(task);
log.debug("新增 worker{}, {}", worker, task);
workers.add(worker);
worker.start();
} else {
// taskQueue.put(task);
// 将拒绝策略封装成一个接口交由调用者自己选择执行什么拒绝策略
// 1) 死等
// 2) 带超时等待
// 3) 让调用者放弃任务执行
// 4) 让调用者抛出异常
// 5) 让调用者自己执行任务
taskQueue.tryPut(rejectPolicy, task);
}
}
}

// 构造方法
public ThreadPool(int coreSize, long timeout, TimeUnit timeUnit, int queueCapcity, RejectPolicy<Runnable> rejectPolicy) {
this.coreSize = coreSize;
this.timeout = timeout;
this.timeUnit = timeUnit;
this.taskQueue = new BlockingQueue<>(queueCapcity);
this.rejectPolicy = rejectPolicy;
}

// Worker的实现
class Worker extends Thread{
// 任务
private Runnable task;

public Worker(Runnable task) {
this.task = task;
}

@Override
public void run() {
// 执行任务
// 1) 当 task 不为空,执行任务
// 2) 当 task 执行完毕,再接着从任务队列获取任务并执行
// while(task != null || (task = taskQueue.take()) != null) {
while(task != null || (task = taskQueue.poll(timeout, timeUnit)) != null) {
try {
log.debug("正在执行...{}", task);
task.run();
} catch (Exception e) {
e.printStackTrace();
} finally {
task = null;
}
}
synchronized (workers) {
log.debug("worker 被移除{}", this);
workers.remove(this);
}
}
}
}
4、步骤4:测试编写好的自定义线程池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class TestPool {
public static void main(String[] args) {
ThreadPool threadPool = new ThreadPool(1,
1000, TimeUnit.MILLISECONDS, 1, (queue, task)->{
// 1. 死等
// queue.put(task);
// 2) 带超时等待
// queue.offer(task, 1500, TimeUnit.MILLISECONDS);
// 3) 让调用者放弃任务执行
// log.debug("放弃{}", task);
// 4) 让调用者抛出异常
// throw new RuntimeException("任务执行失败 " + task);
// 5) 让调用者自己执行任务
task.run();
});
for (int i = 0; i < 4; i++) {
int j = i;
threadPool.execute(() -> {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("{}", j);
});
}
}
}

17、ThreadPool线程池——ThreadPoolExecutor

1、BAT大厂的面试问题

  • 为什么要有线程池?
  • Java是实现和管理线程池有哪些方式?请简单举例如何使用。
  • 为什么很多公司不允许使用Executors去创建线程池?那么推荐怎么使用呢?
  • ThreadPoolExecutor有哪些核心的配置参数?请简要说明
  • ThreadPoolExecutor可以创建哪是哪三种线程池呢?
  • 当队列满了并且worker的数量达到maxSize的时候,会怎么样?
  • 说说ThreadPoolExecutor有哪些RejectedExecutionHandler策略?默认是什么策略?
  • 简要说下线程池的任务执行机制?
    • execute –> addWorker –>runworker (getTask)
  • 线程池中任务是如何提交的?
  • 线程池中任务是如何关闭的?
  • 在配置线程池的时候需要考虑哪些配置因素?
  • 如何监控线程池的状态?

2、为什么需要线程池

线程池能够对线程进行统一分配,调优和监控:

  • 降低资源消耗(线程无限制地创建,然后使用完毕后销毁)
  • 提高响应速度(无须创建线程)
  • 提高线程的可管理性

3、ThreadPoolExecutor例子

Java是如何实现和管理线程池的?

从JDK 5开始,把工作单元与执行机制分离开来,工作单元包括Runnable和Callable,而执行机制由Executor框架提供

WorkerThread:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class WorkerThread implements Runnable {

private String command;

public WorkerThread(String s){
this.command=s;
}

@Override
public void run() {
System.out.println(Thread.currentThread().getName()+" Start. Command = "+command);
processCommand();
System.out.println(Thread.currentThread().getName()+" End.");
}

private void processCommand() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

@Override
public String toString(){
return this.command;
}
}

SimpleThreadPool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SimpleThreadPool {

public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
Runnable worker = new WorkerThread("" + i);
executor.execute(worker);
}
executor.shutdown(); // This will make the executor accept no new threads and finish all existing threads in the queue
while (!executor.isTerminated()) { // Wait until all threads are finish,and also you can use "executor.awaitTermination();" to wait
}
System.out.println("Finished all threads");
}

}

程序中我们创建了固定大小为五个工作线程的线程池。然后分配给线程池十个工作,因为线程池大小为五,它将启动五个工作线程先处理五个工作,其他的工作则处于等待状态,一旦有工作完成,空闲下来工作线程就会捡取等待队列里的其他工作进行执行。

这里是以上程序的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pool-1-thread-2 Start. Command = 1
pool-1-thread-4 Start. Command = 3
pool-1-thread-1 Start. Command = 0
pool-1-thread-3 Start. Command = 2
pool-1-thread-5 Start. Command = 4
pool-1-thread-4 End.
pool-1-thread-5 End.
pool-1-thread-1 End.
pool-1-thread-3 End.
pool-1-thread-3 Start. Command = 8
pool-1-thread-2 End.
pool-1-thread-2 Start. Command = 9
pool-1-thread-1 Start. Command = 7
pool-1-thread-5 Start. Command = 6
pool-1-thread-4 Start. Command = 5
pool-1-thread-2 End.
pool-1-thread-4 End.
pool-1-thread-3 End.
pool-1-thread-5 End.
pool-1-thread-1 End.
Finished all threads

输出表明线程池中至始至终只有五个名为 “pool-1-thread-1” 到 “pool-1-thread-5” 的五个线程,这五个线程不随着工作的完成而消亡,会一直存在,并负责执行分配给线程池的任务,直到线程池消亡。

Executors 类提供了使用了 ThreadPoolExecutor 的简单的 ExecutorService 实现,但是 ThreadPoolExecutor 提供的功能远不止于此。我们可以在创建 ThreadPoolExecutor 实例时指定活动线程的数量,我们也可以限制线程池的大小并且创建我们自己的 RejectedExecutionHandler 实现来处理不能适应工作队列的工作。

这里是我们自定义的 RejectedExecutionHandler 接口的实现:

1
2
3
4
5
6
7
8
9
10
11
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;

public class RejectedExecutionHandlerImpl implements RejectedExecutionHandler {

@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println(r.toString() + " is rejected");
}

}

ThreadPoolExecutor 提供了一些方法,我们可以使用这些方法来查询 executor 的当前状态,线程池大小,活动线程数量以及任务数量。因此我是用来一个监控线程在特定的时间间隔内打印 executor 信息。

MyMonitorThread.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.util.concurrent.ThreadPoolExecutor;

public class MyMonitorThread implements Runnable {

private ThreadPoolExecutor executor;

private int seconds;

private boolean run=true;

public MyMonitorThread(ThreadPoolExecutor executor, int delay) {
this.executor = executor;
this.seconds=delay;
}

public void shutdown(){
this.run=false;
}

@Override
public void run() {
while(run){
System.out.println(
String.format("[monitor] [%d/%d] Active: %d, Completed: %d, Task: %d, isShutdown: %s, isTerminated: %s",
this.executor.getPoolSize(),
this.executor.getCorePoolSize(),
this.executor.getActiveCount(),
this.executor.getCompletedTaskCount(),
this.executor.getTaskCount(),
this.executor.isShutdown(),
this.executor.isTerminated()));
try {
Thread.sleep(seconds*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}
}

这里是使用 ThreadPoolExecutor 的线程池实现例子。

WorkerPool.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class WorkerPool {

public static void main(String args[]) throws InterruptedException{
//RejectedExecutionHandler implementation
RejectedExecutionHandlerImpl rejectionHandler = new RejectedExecutionHandlerImpl();
//Get the ThreadFactory implementation to use
ThreadFactory threadFactory = Executors.defaultThreadFactory();
//creating the ThreadPoolExecutor
ThreadPoolExecutor executorPool = new ThreadPoolExecutor(2, 4, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(2), threadFactory, rejectionHandler);
//start the monitoring thread
MyMonitorThread monitor = new MyMonitorThread(executorPool, 3);
Thread monitorThread = new Thread(monitor);
monitorThread.start();
//submit work to the thread pool
for(int i=0; i<10; i++){
executorPool.execute(new WorkerThread("cmd"+i));
}

Thread.sleep(30000);
//shut down the pool
executorPool.shutdown();
//shut down the monitor thread
Thread.sleep(5000);
monitor.shutdown();

}
}

注意在初始化 ThreadPoolExecutor 时,我们保持初始池大小为 2,最大池大小为 4 而工作队列大小为 2。因此如果已经有四个正在执行的任务而此时分配来更多任务的话,工作队列将仅仅保留他们(新任务)中的两个,其他的将会被 RejectedExecutionHandlerImpl 处理。

上面程序的输出可以证实以上观点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pool-1-thread-1 Start. Command = cmd0
pool-1-thread-4 Start. Command = cmd5
cmd6 is rejected
pool-1-thread-3 Start. Command = cmd4
pool-1-thread-2 Start. Command = cmd1
cmd7 is rejected
cmd8 is rejected
cmd9 is rejected
[monitor] [0/2] Active: 4, Completed: 0, Task: 6, isShutdown: false, isTerminated: false
[monitor] [4/2] Active: 4, Completed: 0, Task: 6, isShutdown: false, isTerminated: false
pool-1-thread-4 End.
pool-1-thread-1 End.
pool-1-thread-2 End.
pool-1-thread-3 End.
pool-1-thread-1 Start. Command = cmd3
pool-1-thread-4 Start. Command = cmd2
[monitor] [4/2] Active: 2, Completed: 4, Task: 6, isShutdown: false, isTerminated: false
[monitor] [4/2] Active: 2, Completed: 4, Task: 6, isShutdown: false, isTerminated: false
pool-1-thread-1 End.
pool-1-thread-4 End.
[monitor] [4/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [0/2] Active: 0, Completed: 6, Task: 6, isShutdown: true, isTerminated: true
[monitor] [0/2] Active: 0, Completed: 6, Task: 6, isShutdown: true, isTerminated: true

注意 executor 的活动任务、完成任务以及所有完成任务,这些数量上的变化。我们可以调用 shutdown() 方法来结束所有提交的任务并终止线程池。

4、ThreadPoolExecutor使用详解

其实java线程池的实现原理很简单,说白了就是一个线程集合workerSet和一个阻塞队列workQueue。当用户向线程池提交一个任务(也就是线程)时,线程池会先将任务放入workQueue中。workerSet中的线程会不断的从workQueue中获取线程然后执行。当workQueue中没有任务的时候,worker就会阻塞,直到队列中有任务了就取出来继续执行。

java-thread-x-executors-1

1、Execute原理

当一个任务提交至线程池之后:

  1. 线程池首先当前运行的线程数量是否少于corePoolSize。如果是,则创建一个新的工作线程来执行任务。如果都在执行任务,则进入2.
  2. 判断BlockingQueue是否已经满了,倘若还没有满,则将线程放入BlockingQueue。否则进入3.
  3. 如果创建一个新的工作线程将使当前运行的线程数量超过maximumPoolSize,则交给RejectedExecutionHandler来处理任务。

当ThreadPoolExecutor创建新线程时,通过CAS来更新线程池的状态ctl。

2、参数
1
2
3
4
5
6
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler)
  • corePoolSize 线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize,即使有其他空闲线程能够执行新来的任务,也会继续创建线程;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。
  • workQueue 用来保存等待被执行的任务的阻塞队列。在JDK中提供了如下阻塞队列:
    • ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
    • LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;
    • SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;
    • PriorityBlockingQuene:具有优先级的无界阻塞队列;

LinkedBlockingQueueArrayBlockingQueue在插入删除节点性能方面更优,但是二者在put(), take()任务的时均需要加锁,SynchronousQueue使用无锁算法,根据节点的状态判断执行,而不需要用到锁,其核心是Transfer.transfer()

  • maximumPoolSize 线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;当阻塞队列是无界队列,则maximumPoolSize则不起作用,因为无法提交至核心线程池的线程会一直持续地放入workQueue。
  • keepAliveTime 线程空闲时的存活时间,即当线程没有任务执行时,该线程继续存活的时间;默认情况下,该参数只在线程数大于corePoolSize时才有用,超过这个时间的空闲线程将被终止; ——针对救急线程
  • unit keepAliveTime的单位 —— 针对救急线程
  • threadFactory 创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。默认为DefaultThreadFactory
  • handler 线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
    • AbortPolicy:直接抛出异常,默认策略;
    • CallerRunsPolicy:用调用者所在的线程来执行任务;
    • DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
    • DiscardPolicy:直接丢弃任务;

当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义拒绝策略,如记录日志或持久化存储不能处理的任务

image-20210811040044445

根据这个构造方法,JDK Executors 类中提供了众多工厂方法来创建各种用途的线程池

3、三种类型
1、newFixedThreadPool
1
2
3
4
5
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

特点:

  • 线程池的线程数量达corePoolSize后,即使线程池没有可执行任务时,也不会释放线程。
  • 核心线程数 == 最大线程数(没有救急线程被创建),因此也无需超时时间
  • 阻塞队列是无界的,可以放任意数量的任务

FixedThreadPool的工作队列为无界队列LinkedBlockingQueue(队列容量为Integer.MAX_VALUE),这会导致以下问题:

  • 线程池里的线程数量不超过corePoolSize,这导致了maximumPoolSize和keepAliveTime将会是个无用参数
  • 由于使用了无界队列,所以FixedThreadPool永远不会拒绝,即饱和策略失效

评价:适用于任务量已知,相对耗时的任务

2、newSingleThreadPool
1
2
3
4
5
6
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}

特点:

  • 初始化的线程池中只有一个线程,如果该线程异常结束,会重新创建一个新的线程继续执行任务,唯一的线程可以保证所提交任务的顺序执行。

由于使用了无界队列,所以SingleThreadPool永远不会拒绝,即饱和策略失效。

使用场景:

  • 希望多个任务排队执行。
  • 线程数固定为 1,任务数多于 1 时,会放入无界队列排队。
  • 任务执行完毕,这唯一的线程也不会被释放。
3、newCachedThreadPool
1
2
3
4
5
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

特点:

  • 核心线程数是 0, 最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着
    • 全部都是救急线程(60s 后可以回收)
    • 救急线程可以无限创建
  • 队列采用了 SynchronousQueue 实现特点是,它没有容量,没有线程来取是放不进去的(一手交钱、一手交货)

线程池的线程数可达到Integer.MAX_VALUE,即2147483647,内部使用SynchronousQueue作为阻塞队列; 和newFixedThreadPool创建的线程池不同,newCachedThreadPool在没有任务执行时,当线程的空闲时间超过keepAliveTime,会自动释放线程资源,当提交新任务时,如果没有空闲线程,则创建新线程执行任务,会导致一定的系统开销; 执行过程与前两种稍微不同:

  1. 主线程调用SynchronousQueue的offer()方法放入task,倘若此时线程池中有空闲的线程尝试读取 SynchronousQueue的task,即调用了SynchronousQueue的poll(),那么主线程将该task交给空闲线程。否则执行(2)
  2. 当线程池为空或者没有空闲的线程,则创建新的线程执行任务。
  3. 执行完任务的线程倘若在60s内仍空闲,则会被终止。因此长时间空闲的CachedThreadPool不会持有任何线程资源。

评价:整个线程池表现为线程数会根据任务量不断增长,没有上限,当任务执行完毕,空闲 1分钟后释放线程。 适合任务数比较密集,但每个任务执行时间较短的情况

4、区别
  • 自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,而线程池还会新建一个线程,保证池的正常工作
  • Executors.newSingleThreadExecutor() 线程个数始终为1,不能修改
    • FinalizableDelegatedExecutorService 应用的是==装饰器模式==,只对外暴露了 ExecutorService 接口,因此不能调用 ThreadPoolExecutor 中特有的方法
  • Executors.newFixedThreadPool(1) 初始时为1,以后还可以修改
    • 对外暴露的是 ThreadPoolExecutor 对象,可以强转后调用 setCorePoolSize 等方法进行修改
4、关闭线程池

遍历线程池中的所有线程,然后逐个调用线程的interrupt方法来中断线程。

1、关闭方式——shutdown

将线程池里的线程状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。

2、关闭方式——shutdownNow

将线程池里的线程状态设置成STOP状态,然后停止所有正在执行或暂停任务的线程。只要调用这两个关闭方法中的任意一个,isShutDown() 返回true。当所有任务都成功关闭了,isTerminated()返回true。

5、ThreadPoolExecutor源码详解

1、几个关键属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//这个属性是用来存放 当前运行的worker数量以及线程池状态的
//int是32位的,这里把int的高3位拿来充当线程池状态的标志位,后29位拿来充当当前运行worker的数量
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
//存放任务的阻塞队列
private final BlockingQueue<Runnable> workQueue;
//worker的集合,用set来存放
private final HashSet<Worker> workers = new HashSet<Worker>();
//历史达到的worker数最大值
private int largestPoolSize;
//当队列满了并且worker的数量达到maxSize的时候,执行具体的拒绝策略
private volatile RejectedExecutionHandler handler;
//超出coreSize的worker的生存时间
private volatile long keepAliveTime;
//常驻worker的数量
private volatile int corePoolSize;
//最大worker的数量,一般当workQueue满了才会用到这个参数
private volatile int maximumPoolSize;
2、内部状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

// Packing and unpacking ctl
private static int runStateOf(int c) { return c & ~CAPACITY; }
private static int workerCountOf(int c) { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }

其中AtomicInteger变量ctl的功能非常强大:利用低29位表示线程池中线程数,通过高3位表示线程池的运行状态:

  • RUNNING:-1 << COUNT_BITS,即高3位为111该状态的线程池会接收新任务,并处理阻塞队列中的任务
  • SHUTDOWN:0 << COUNT_BITS,即高3位为000该状态的线程池不会接收新任务,但会处理阻塞队列中的任务
  • STOP:1 << COUNT_BITS,即高3位为001该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务
  • TIDYING:2 << COUNT_BITS,即高3位为010所有任务全执行完毕,活动线程为 0 即将进入终结
  • TERMINATED:3 << COUNT_BITS,即高3位为011terminated()方法已经执行完成

img

状态名 高3位 接收新任务 处理阻塞队列任务 说明
RUNNING 111 Y Y
SHUTDOWN 000 N Y 不会接收新任务,但会处理阻塞队列剩余任务
STOP 001 N N 会中断正在执行的任务,并抛弃阻塞队列任务
TIDYING 010 - - 任务全执行完毕,活动线程为 0 即将进入终结
TERMINATED 011 - - 终结状态

从数字上比较,TERMINATED > TIDYING > STOP > SHUTDOWN > RUNNING

注意:为什么RUNNING为111确是最小的?

因为计算机都是补码来记录,所以111其实是-1

这些信息存储在一个原子变量 ctl 中,目的是将线程池状态与线程个数合二为一,这样就可以用一次 cas 原子操作进行赋值

1
2
3
4
// c 为旧值, ctlOf 返回结果为新值
ctl.compareAndSet(c, ctlOf(targetState, workerCountOf(c))));
// rs 为高 3 位代表线程池状态, wc 为低 29 位代表线程个数,ctl 是合并它们
private static int ctlOf(int rs, int wc) { return rs | wc; }
3、任务的执行

execute –> addWorker –>runworker (getTask)

线程池的工作线程通过Woker类实现,在ReentrantLock锁的保证下,把Woker实例插入到HashSet后,并启动Woker中的线程。 从Woker类的构造方法实现可以发现:线程工厂在创建线程thread时,将Woker实例本身this作为参数传入,当执行start方法启动线程thread时,本质是执行了Worker的runWorker方法。 firstTask执行完成之后,通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;

1、execute()方法

ThreadPoolExecutor.execute(task)实现了Executor.execute(task)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
//workerCountOf获取线程池的当前线程数;小于corePoolSize,执行addWorker创建新线程执行command任务
if (addWorker(command, true))
return;
c = ctl.get();
}
// double check: c, recheck
// 线程池处于RUNNING状态,把提交的任务成功放入阻塞队列中
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// recheck and if necessary 回滚到入队操作前,即倘若线程池shutdown状态,就remove(command)
//如果线程池没有RUNNING,成功从阻塞队列中删除任务,执行reject方法处理任务
if (! isRunning(recheck) && remove(command))
reject(command);
//线程池处于running状态,但是没有线程,则创建线程
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 往线程池中创建新的线程失败,则reject任务
else if (!addWorker(command, false))
reject(command);
}

为什么需要double check线程池的状态?

在多线程环境下,线程池的状态时刻在变化,而ctl.get()是非原子操作,很有可能刚获取了线程池状态后线程池状态就改变了。判断是否将command加入workque是线程池之前的状态。倘若没有double check,万一线程池处于非running状态(在多线程环境下很有可能发生),那么command永远不会执行。

2、addWorker方法

从方法execute的实现可以看出:addWorker主要负责创建新的线程并执行任务线程池创建新线程执行任务时,需要 获取全局锁:

1
private final ReentrantLock mainLock = new ReentrantLock();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
private boolean addWorker(Runnable firstTask, boolean core) {
// CAS更新线程池数量
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
// 线程池重入锁
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start(); // 线程启动,执行任务(Worker.thread(firstTask).start());
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
3、Worker类的runworker方法
1
2
3
4
5
6
7
8
9
10
11
12
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this); // 创建线程
}
/** Delegates main run loop to outer runWorker */
public void run() {
runWorker(this);
}
// ...
}
  • 继承了AQS类,可以方便的实现工作线程的中止操作;
  • 实现了Runnable接口,可以将自身作为一个任务在工作线程中执行;
  • 当前提交的任务firstTask作为参数传入Worker的构造方法;

一些属性还有构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
//运行的线程,前面addWorker方法中就是直接通过启动这个线程来启动这个worker
final Thread thread;
//当一个worker刚创建的时候,就先尝试执行这个任务
Runnable firstTask;
//记录完成任务的数量
volatile long completedTasks;

Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
//创建一个Thread,将自己设置给他,后面这个thread启动的时候,也就是执行worker的run方法
this.thread = getThreadFactory().newThread(this);
}

runWorker方法是线程池的核心:

  • 线程启动之后,通过unlock方法释放锁,设置AQS的state为0,表示运行可中断;
  • Worker执行firstTask或从workQueue中获取任务:
    • 进行加锁操作,保证thread不被其他线程中断(除非线程池被中断)
    • 检查线程池状态,倘若线程池处于中断状态,当前线程将中断。
    • 执行beforeExecute
    • 执行任务的run方法
    • 执行afterExecute方法
    • 解锁操作

通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
// 先执行firstTask,再从workerQueue中取task(getTask())

while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
4、getTask方法

下面来看一下getTask()方法,这里面涉及到keepAliveTime的使用,从这个方法我们可以看出线程池是怎么让超过corePoolSize的那部分worker销毁的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?

for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}

try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

注意这里一段代码是keepAliveTime起作用的关键:

1
2
3
4
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();

allowCoreThreadTimeOut为false,线程即使空闲也不会被销毁;倘若为ture,在keepAliveTime内仍空闲则会被销毁。

如果线程允许空闲等待而不被销毁timed == false,workQueue.take任务:如果阻塞队列为空,当前线程会被挂起等待;当队列中有任务加入时,线程被唤醒,take方法返回任务,并执行;

如果线程不允许无休止空闲timed == true,workQueue.poll任务:如果在keepAliveTime时间内,阻塞队列还是没有任务,则返回null;

4、任务的提交
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 执行任务
void execute(Runnable command);

// 提交任务 task,用返回值 Future 获得任务执行结果
<T> Future<T> submit(Callable<T> task);

// 提交 tasks 中所有任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;

// 提交 tasks 中所有任务,带超时时间
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit) throws InterruptedException;

// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;

// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消,带超时时间
<T> T invokeAny(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;

img

  1. submit任务,等待线程池execute
  2. 执行FutureTask类的get方法时,会把主线程封装成WaitNode节点并保存在waiters链表中, 并阻塞等待运行结果;
  3. FutureTask任务执行完成后,通过UNSAFE设置waiters相应的waitNode为null,并通过LockSupport类unpark方法唤醒主线程;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Test{
public static void main(String[] args) {

ExecutorService es = Executors.newCachedThreadPool();
Future<String> future = es.submit(new Callable<String>() {
@Override
public String call() throws Exception {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "future result";
}
});
try {
String result = future.get();
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}

在实际业务场景中,Future和Callable基本是成对出现的,Callable负责产生结果,Future负责获取结果。

  1. Callable接口类似于Runnable,只是Runnable没有返回值。
  2. Callable任务除了返回正常结果之外,如果发生异常,该异常也会被返回,即Future可以拿到异步执行任务各种结果;
  3. Future.get方法会导致主线程阻塞,直到Callable任务执行完成;
1、submit方法

AbstractExecutorService.submit()实现了ExecutorService.submit() 可以获取执行完的返回值,而ThreadPoolExecutor 是AbstractExecutorService.submit()的子类,所以submit方法也是ThreadPoolExecutor的方法。

1
2
3
4
5
6
// submit()在ExecutorService中的定义
<T> Future<T> submit(Callable<T> task);

<T> Future<T> submit(Runnable task, T result);

Future<?> submit(Runnable task);
1
2
3
4
5
6
7
8
// submit方法在AbstractExecutorService中的实现
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
// 通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}

通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。通过Executor.execute方法提交FutureTask到线程池中等待被执行,最终执行的是FutureTask的run方法;

2、FutureTask对象

public class FutureTask<V> implements RunnableFuture<V> 可以将FutureTask提交至线程池中等待被执行(通过FutureTask的run方法来执行)

  • 内部状态

    • /* The run state of this task, initially NEW. 
          * ...
          * Possible state transitions:
          * NEW -> COMPLETING -> NORMAL
          * NEW -> COMPLETING -> EXCEPTIONAL
          * NEW -> CANCELLED
          * NEW -> INTERRUPTING -> INTERRUPTED
          */
      private volatile int state;
      private static final int NEW          = 0;
      private static final int COMPLETING   = 1;
      private static final int NORMAL       = 2;
      private static final int EXCEPTIONAL  = 3;
      private static final int CANCELLED    = 4;
      private static final int INTERRUPTING = 5;
      private static final int INTERRUPTED  = 6;
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 内部状态的修改通过sun.misc.Unsafe修改

      - get方法

      ```java

      public V get() throws InterruptedException, ExecutionException {
      int s = state;
      if (s <= COMPLETING)
      s = awaitDone(false, 0L);
      return report(s);
      }
    • 内部通过awaitDone方法对主线程进行阻塞,具体实现如下:

    • private int awaitDone(boolean timed, long nanos)
          throws InterruptedException {
          final long deadline = timed ? System.nanoTime() + nanos : 0L;
          WaitNode q = null;
          boolean queued = false;
          for (;;) {
              if (Thread.interrupted()) {
                  removeWaiter(q);
                  throw new InterruptedException();
              }
      
              int s = state;
              if (s > COMPLETING) {
                  if (q != null)
                      q.thread = null;
                  return s;
              }
              else if (s == COMPLETING) // cannot time out yet
                  Thread.yield();
              else if (q == null)
                  q = new WaitNode();
              else if (!queued)
                  queued = UNSAFE.compareAndSwapObject(this, waitersOffset,q.next = waiters, q);
              else if (timed) {
                  nanos = deadline - System.nanoTime();
                  if (nanos <= 0L) {
                      removeWaiter(q);
                      return state;
                  }
                  LockSupport.parkNanos(this, nanos);
              }
              else
                  LockSupport.park(this);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44

      - 如果主线程被中断,则抛出中断异常;

      - 判断FutureTask当前的state,如果大于COMPLETING,说明任务已经执行完成,则直接返回;

      - 如果当前state等于COMPLETING,说明任务已经执行完,这时主线程只需通过yield方法让出cpu资源,等待state变成NORMAL;

      - 通过WaitNode类封装当前线程,并通过UNSAFE添加到waiters链表;

      - 最终通过LockSupport的park或parkNanos挂起线程;

      - run方法

      - ```java
      public void run() {
      if (state != NEW || !UNSAFE.compareAndSwapObject(this, runnerOffset, null, Thread.currentThread()))
      return;
      try {
      Callable<V> c = callable;
      if (c != null && state == NEW) {
      V result;
      boolean ran;
      try {
      result = c.call();
      ran = true;
      } catch (Throwable ex) {
      result = null;
      ran = false;
      setException(ex);
      }
      if (ran)
      set(result);
      }
      } finally {
      // runner must be non-null until state is settled to
      // prevent concurrent calls to run()
      runner = null;
      // state must be re-read after nulling runner to prevent
      // leaked interrupts
      int s = state;
      if (s >= INTERRUPTING)
      handlePossibleCancellationInterrupt(s);
      }
      }
    • FutureTask.run方法是在线程池中被执行的,而非主线程:

      1. 通过执行Callable任务的call方法;
      2. 如果call执行成功,则通过set方法保存结果;
      3. 如果call执行有异常,则通过setException保存异常;
5、任务的关闭

shutdown方法会将线程池的状态设置为SHUTDOWN,线程池进入这个状态后,就拒绝再接受任务,然后会将剩余的任务全部执行完:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
线程池状态变为 SHUTDOWN
- 不会接收新任务
- 但已提交任务会执行完
- 此方法不会阻塞调用线程的执行
*/
public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//检查是否可以关闭线程
checkShutdownAccess();
//设置线程池状态
advanceRunState(SHUTDOWN);
//尝试中断worker,仅会打断空闲线程
interruptIdleWorkers();
//预留方法,留给子类实现
onShutdown(); // hook for ScheduledThreadPoolExecutor——扩展点
} finally {
mainLock.unlock();
}
// 尝试终结(没有运行的线程可以立刻终结,如果还有运行的线程也不会等)
tryTerminate();
}

private void interruptIdleWorkers() {
interruptIdleWorkers(false);
}

private void interruptIdleWorkers(boolean onlyOne) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//遍历所有的worker
for (Worker w : workers) {
Thread t = w.thread;
//先尝试调用w.tryLock(),如果获取到锁,就说明worker是空闲的,就可以直接中断它
//注意的是,worker自己本身实现了AQS同步框架,然后实现的类似锁的功能
//它实现的锁是不可重入的,所以如果worker在执行任务的时候,会先进行加锁,这里tryLock()就会返回false
if (!t.isInterrupted() && w.tryLock()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
} finally {
w.unlock();
}
}
if (onlyOne)
break;
}
} finally {
mainLock.unlock();
}
}

shutdownNow做的比较绝,它先将线程池状态设置为STOP,然后拒绝所有提交的任务。最后中断左右正在运行中的worker,然后清空任务队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
线程池状态变为 STOP
- 不会接收新任务
- 会将队列中的任务返回
- 并用 interrupt 的方式中断正在执行的任务
*/
public List<Runnable> shutdownNow() {
List<Runnable> tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
//检测权限,修改线程池状态
advanceRunState(STOP);
//中断所有的worker,打断所有线程
interruptWorkers();
//获取队列中剩余任务,清空任务队列
tasks = drainQueue();
} finally {
mainLock.unlock();
}
// 尝试终结(没有运行的线程可以立刻终结,如果还有运行的线程也不会等)
tryTerminate();
return tasks;
}

private void interruptWorkers() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//遍历所有worker,然后调用中断方法
for (Worker w : workers)
w.interruptIfStarted();
} finally {
mainLock.unlock();
}
}
6、其他方法
1
2
3
4
5
6
7
8
// 不在 RUNNING 状态的线程池,此方法就返回 true
boolean isShutdown();

// 线程池状态是否是 TERMINATED
boolean isTerminated();

// 调用 shutdown 后,由于调用线程并不会等待所有任务运行结束,因此如果它想在线程池 TERMINATED 后做些事情,可以利用此方法等待
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;

6、异常的处理

使用线程池创建线程时,如果线程内部发生异常的话,是不会抛出或者在控制台打印异常信息的,所以需要我们对可能出现异常进行异常处理,对于异常的处理有以下几种方法:

  • 线程自己捕捉:线程在代码里对可能出现的异常进行try catch捕捉

    • ExecutorService pool = Executors.newFixedThreadPool(1);
      pool.submit(() -> {
          try {
              log.debug("task1");
              int i = 1 / 0;
          } catch (Exception e) {
              log.error("error:", e);
          }
      });
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 通过Future进行结果的返回来判断是否发生异常:

      - ```java
      ExecutorService pool = Executors.newFixedThreadPool(1);
      Future<Boolean> f = pool.submit(() -> {
      log.debug("task1");
      inti = 1/0;
      return true ;
      });
      Log. debug("result:{}", f.get();

7、更深入理解

1、为什么线程池不允许使用Executors去创建?

线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:

  • newFixedThreadPool和newSingleThreadExecutor:  主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
  • newCachedThreadPool和newScheduledThreadPool:  主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
1、推荐方式1

首先引入:commons-lang3包

1
2
ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1,
new BasicThreadFactory.Builder().namingPattern("example-schedule-pool-%d").daemon(true).build());
2、推荐方式2

首先引入:com.google.guava包

1
2
3
4
5
6
7
8
9
10
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build();

//Common Thread Pool
ExecutorService pool = new ThreadPoolExecutor(5, 200, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());

// excute
pool.execute(()-> System.out.println(Thread.currentThread().getName()));

//gracefully shutdown
pool.shutdown();
3、推荐方式3

spring配置线程池方式:自定义线程工厂bean需要实现ThreadFactory,可参考该接口的其它默认实现类,使用方式直接注入bean调用execute(Runnable task)方法即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
<bean id="userThreadPool" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
<property name="corePoolSize" value="10" />
<property name="maxPoolSize" value="100" />
<property name="queueCapacity" value="2000" />

<property name="threadFactory" value= threadFactory />
<property name="rejectedExecutionHandler">
<ref local="rejectedExecutionHandler" />
</property>
</bean>

//in code
userThreadPool.execute(thread);
2、配置线程池需要考虑的因素

从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系这四个角度来分析。并且近可能地使用有界的工作队列。

性质不同的任务可用使用不同规模的线程池分开处理:

  • CPU密集型:尽可能少的线程,Ncpu+1
  • IO密集型:尽可能多的线程,Ncpu*2,比如数据库连接池
  • 混合型:CPU密集型的任务与IO密集型任务的执行时间差别较小,拆分为两个线程池;否则没有必要拆分。

具体也可以参考8、并发的多线程设计模式的7、工作线程模式查看

3、监控线程池的状态

可以使用ThreadPoolExecutor以下方法:

  • getTaskCount() Returns the approximate total number of tasks that have ever been scheduled for execution.
    • 返回计划执行的任务的大致总数。
  • getCompletedTaskCount() Returns the approximate total number of tasks that have completed execution.
    • 返回已完成执行的任务的大致总数
    • 返回结果少于getTaskCount()。
  • getLargestPoolSize() Returns the largest number of threads that have ever simultaneously been in the pool.
    • 返回池中同时存在的最大线程数。
    • 返回结果小于等于maximumPoolSize
  • getPoolSize() Returns the current number of threads in the pool.
    • 返回池中当前的线程数。
  • getActiveCount() Returns the approximate number of threads that are actively executing tasks.
    • 返回当前正在执行任务的线程的大致数目。

18、ThreadPool线程池——ScheduledThreadPoolExecutor

在『任务调度线程池』功能加入之前,可以使用 java.util.Timer 来实现定时功能,Timer 的优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务

如果使用的是ScheduledThreadPoolExecutor前一个任务的延迟或异常都不会影响到之后的任务,但是异常信息不会被打印出来。需要我们对异常信息进行处理

在很多业务场景中,我们可能需要周期性的运行某项任务来获取结果,比如周期数据统计,定时发送数据等。在并发包出现之前,Java 早在1.3就提供了 Timer 类(只需要了解,目前已渐渐被 ScheduledThreadPoolExecutor 代替)来适应这些业务场景。随着业务量的不断增大,我们可能需要多个工作线程运行任务来尽可能的增加产品性能,或者是需要更高的灵活性来控制和监控这些周期业务。这些都是 ScheduledThreadPoolExecutor 诞生的必然性。

1、BAT大厂的面试问题

  • ScheduledThreadPoolExecutor要解决什么样的问题?
  • ScheduledThreadPoolExecutor相比ThreadPoolExecutor有哪些特性?
  • ScheduledThreadPoolExecutor有什么样的数据结构,核心内部类和抽象类?
  • ScheduledThreadPoolExecutor有哪两个关闭策略?区别是什么?
  • ScheduledThreadPoolExecutor中scheduleAtFixedRate 和 scheduleWithFixedDelay区别是什么?
  • 为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?
  • Executors 提供了几种方法来构造 ScheduledThreadPoolExecutor?

2、ScheduledThreadPoolExecutor简介

ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,为任务提供延迟或周期执行,属于线程池的一种。和 ThreadPoolExecutor 相比,它还具有以下几种特性:

  • 使用专门的任务类型——ScheduledFutureTask 来执行周期任务,也可以接收不需要时间调度的任务(这些任务通过 ExecutorService 来执行)。
  • 使用专门的存储队列——DelayedWorkQueue 来存储任务,DelayedWorkQueue 是无界延迟队列DelayQueue 的一种。相比ThreadPoolExecutor也简化了执行机制(delayedExecute方法,后面单独分析)。
  • 支持可选的run-after-shutdown参数,在池被关闭(shutdown)之后支持可选的逻辑来决定是否继续运行周期或延迟任务。并且当任务(重新)提交操作与 shutdown 操作重叠时,复查逻辑也不相同。

3、ScheduledThreadPoolExecutor数据结构

img

ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,ScheduledThreadPoolExecutor 内部构造了两个内部类 ScheduledFutureTaskDelayedWorkQueue:

  • ScheduledFutureTask:继承了FutureTask,说明是一个异步运算任务;最上层分别实现了Runnable、Future、Delayed接口,说明它是一个可以延迟执行的异步运算任务
  • DelayedWorkQueue:这是 ScheduledThreadPoolExecutor 为存储周期或延迟任务专门定义的一个延迟队列,继承了 AbstractQueue,为了契合 ThreadPoolExecutor 也实现了 BlockingQueue 接口。它内部只允许存储 RunnableScheduledFuture 类型的任务。与 DelayQueue 的不同之处就是它只允许存放 RunnableScheduledFuture 对象,并且自己实现了二叉堆(DelayQueue 是利用了 PriorityQueue 的二叉堆结构)。

4、ScheduledThreadPoolExecutor源码解析

1、内部类ScheduledFutureTask
1、属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//为相同延时任务提供的顺序编号
private final long sequenceNumber;

//任务可以执行的时间,纳秒级
private long time;

//重复任务的执行周期时间,纳秒级。
private final long period;

//重新入队的任务
RunnableScheduledFuture<V> outerTask = this;

//延迟队列的索引,以支持更快的取消操作
int heapIndex;
  • sequenceNumber:当两个任务有相同的延迟时间时,按照 FIFO 的顺序入队。sequenceNumber 就是为相同延时任务提供的顺序编号
  • time任务可以执行时的时间,==纳秒级==,通过triggerTime方法计算得出。
  • period:任务的执行周期时间,==纳秒级==。
    • 正数表示固定速率执行(为scheduleAtFixedRate提供服务),
    • 负数表示固定延迟执行(为scheduleWithFixedDelay提供服务),
    • 0表示不重复任务。
  • outerTask重新入队的任务,通过reExecutePeriodic方法入队重新排序。
2、核心方法run()
1
2
3
4
5
6
7
8
9
10
11
12
public void run() {
boolean periodic = isPeriodic();//是否为周期任务
if (!canRunInCurrentRunState(periodic))//当前状态是否可以执行
cancel(false);
else if (!periodic)
//不是周期任务,直接执行
ScheduledFutureTask.super.run();
else if (ScheduledFutureTask.super.runAndReset()) {
setNextRunTime();//设置下一次运行时间
reExecutePeriodic(outerTask);//重排序一个周期任务
}
}

说明:ScheduledFutureTask 的run方法重写了 FutureTask 的版本,以便执行周期任务时重置/重排序任务。任务的执行通过父类 FutureTask 的run实现。

内部有两个针对周期任务的方法:

  • setNextRunTime()用来设置下一次运行的时间,源码如下:

    • //设置下一次执行任务的时间
      private void setNextRunTime() {
          long p = period;
          if (p > 0)  //固定速率执行,scheduleAtFixedRate
              time += p;
          else
              time = triggerTime(-p);  //固定延迟执行,scheduleWithFixedDelay
      }
      //计算固定延迟任务的执行时间
      long triggerTime(long delay) {
          return now() +
              ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay));
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15

      - `reExecutePeriodic()`:**周期任务重新入队等待下一次执行**,源码如下:

      - ```java
      //重排序一个周期任务
      void reExecutePeriodic(RunnableScheduledFuture<?> task) {
      if (canRunInCurrentRunState(true)) {//池关闭后可继续执行
      super.getQueue().add(task);//任务入列
      //重新检查run-after-shutdown参数,如果不能继续运行就移除队列任务,并取消任务的执行
      if (!canRunInCurrentRunState(true) && remove(task))
      task.cancel(false);
      else
      ensurePrestart();//启动一个新的线程等待任务
      }
      }

reExecutePeriodic与delayedExecute的执行策略一致,只不过reExecutePeriodic不会执行拒绝策略而是直接丢掉任务

3、cancel方法
1
2
3
4
5
6
public boolean cancel(boolean mayInterruptIfRunning) {
boolean cancelled = super.cancel(mayInterruptIfRunning);
if (cancelled && removeOnCancel && heapIndex >= 0)
remove(this);
return cancelled;
}

ScheduledFutureTask.cancel本质上由其父类 FutureTask.cancel 实现取消任务成功后会根据removeOnCancel参数决定是否从队列中移除此任务。

2、核心属性
1
2
3
4
5
6
7
8
9
10
11
//关闭后继续执行已经存在的周期任务 
private volatile boolean continueExistingPeriodicTasksAfterShutdown;

//关闭后继续执行已经存在的延时任务
private volatile boolean executeExistingDelayedTasksAfterShutdown = true;

//取消任务后移除
private volatile boolean removeOnCancel = false;

//为相同延时的任务提供的顺序编号,保证任务之间的FIFO顺序
private static final AtomicLong sequencer = new AtomicLong();
  • continueExistingPeriodicTasksAfterShutdownexecuteExistingDelayedTasksAfterShutdownScheduledThreadPoolExecutor 定义的 run-after-shutdown 参数,用来控制池关闭之后的任务执行逻辑。
  • removeOnCancel用来控制任务取消后是否从队列中移除。当一个已经提交的周期或延迟任务在运行之前被取消,那么它之后将不会运行。默认配置下,这种已经取消的任务在届期之前不会被移除。 通过这种机制,可以方便检查和监控线程池状态,但也可能导致已经取消的任务无限滞留。为了避免这种情况的发生,我们可以通过setRemoveOnCancelPolicy方法设置移除策略,把参数removeOnCancel设为true可以在任务取消后立即从队列中移除。
  • sequencer是为相同延时的任务提供的顺序编号,保证任务之间的 FIFO 顺序。与 ScheduledFutureTask 内部的sequenceNumber参数作用一致。
3、构造函数

首先看下构造函数,ScheduledThreadPoolExecutor 内部有四个构造函数,这里我们只看这个最大构造灵活度的:

1
2
3
4
5
6
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler);
}

构造函数都是通过super调用了ThreadPoolExecutor的构造,并且使用特定等待队列DelayedWorkQueue

4、核心方法——Schedule
1
2
3
4
5
6
7
8
9
10
public <V> ScheduledFuture<V> schedule(Callable<V> callable,
long delay,
TimeUnit unit) {
if (callable == null || unit == null)
throw new NullPointerException();
RunnableScheduledFuture<V> t = decorateTask(callable,
new ScheduledFutureTask<V>(callable, triggerTime(delay, unit)));//构造ScheduledFutureTask任务
delayedExecute(t);//任务执行主方法
return t;
}

说明:schedule主要用于执行一次性(延迟)任务。函数执行逻辑分两步:

  • 封装 Callable/Runnable: 首先通过triggerTime计算任务的延迟执行时间,然后通过 ScheduledFutureTask 的构造函数把 Runnable/Callable 任务构造为ScheduledThreadPoolExecutor可以执行的任务类型,最后调用decorateTask方法执行用户自定义的逻辑;decorateTask是一个用户可自定义扩展的方法,默认实现下直接返回封装的RunnableScheduledFuture任务,源码如下:

    • protected <V> RunnableScheduledFuture<V> decorateTask(
          Runnable runnable, RunnableScheduledFuture<V> task) {
          return task;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17

      - `执行任务`:**通过delayedExecute实现**。下面我们来详细分析:

      - ```java
      private void delayedExecute(RunnableScheduledFuture<?> task) {
      if (isShutdown())
      reject(task);//池已关闭,执行拒绝策略
      else {
      super.getQueue().add(task);//任务入队
      if (isShutdown() &&
      !canRunInCurrentRunState(task.isPeriodic()) &&//判断run-after-shutdown参数
      remove(task))//移除任务
      task.cancel(false);
      else
      ensurePrestart();//启动一个新的线程等待任务
      }
      }

说明:delayedExecute是执行任务的主方法,方法执行逻辑如下:

  • 如果池已关闭(ctl >= SHUTDOWN),执行任务拒绝策略

  • 池正在运行,首先把任务入队排序;然后重新检查池的关闭状态,执行如下逻辑:

    1. A如果池正在运行,或者 run-after-shutdown 参数值为true,则调用父类方法ensurePrestart启动一个新的线程等待执行任务。ensurePrestart源码如下:

      • void ensurePrestart() {
            int wc = workerCountOf(ctl.get());
            if (wc < corePoolSize)
                addWorker(null, true);
            else if (wc == 0)
                addWorker(null, false);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55

        - ensurePrestart是父类 ThreadPoolExecutor 的方法,用于启动一个新的工作线程等待执行任务,即使corePoolSize为0也会安排一个新线程。

        2. `B`:**如果池已经关闭,并且 run-after-shutdown 参数值为false,则执行父类(ThreadPoolExecutor)方法remove移除队列中的指定任务,成功移除后调用ScheduledFutureTask.cancel取消任务**

        ##### 5、核心方法——scheduleAtFixedRate 和 scheduleWithFixedDelay

        ```java
        /**
        * 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
        * 之后每隔period执行一次,不等待第一次执行完成就开始计时
        */
        public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
        long initialDelay,
        long period,
        TimeUnit unit) {
        if (command == null || unit == null)
        throw new NullPointerException();
        if (period <= 0)
        throw new IllegalArgumentException();
        //构建RunnableScheduledFuture任务类型
        ScheduledFutureTask<Void> sft =
        new ScheduledFutureTask<Void>(command,
        null,
        triggerTime(initialDelay, unit),//计算任务的延迟时间
        unit.toNanos(period));//计算任务的执行周期
        RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
        sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
        delayedExecute(t);//执行任务
        return t;
        }

        /**
        * 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
        * 在第一次执行完之后延迟delay后开始下一次执行
        */
        public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
        long initialDelay,
        long delay,
        TimeUnit unit) {
        if (command == null || unit == null)
        throw new NullPointerException();
        if (delay <= 0)
        throw new IllegalArgumentException();
        //构建RunnableScheduledFuture任务类型
        ScheduledFutureTask<Void> sft =
        new ScheduledFutureTask<Void>(command,
        null,
        triggerTime(initialDelay, unit),//计算任务的延迟时间
        unit.toNanos(-delay));//计算任务的执行周期
        RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
        sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
        delayedExecute(t);//执行任务
        return t;
        }

说明:scheduleAtFixedRate和scheduleWithFixedDelay方法的逻辑与schedule类似。

注意scheduleAtFixedRate和scheduleWithFixedDelay的区别: 乍一看两个方法一模一样,其实,在unit.toNanos这一行代码中还是有区别的:

  • 没错,scheduleAtFixedRate传的是正值,而scheduleWithFixedDelay传的则是负值,这个值就是 ScheduledFutureTask 的period属性。
  • 执行效果上也有区别:
    • 对于scheduleAtFixedRate来说:如果间隔时间小于线程执行任务的时间(例如:间隔时间为1s,然而线程要执行2s),那么将会影响到间隔的时间——间隔时间无效,会等到任务执行完毕在执行下一个任务
    • 而对于scheduleWithFixedDelay来说:如果间隔时间小于线程执行任务的时间(例如:间隔时间为1s,然而线程要执行2s),那么间隔的时间会增加——间隔的时间(3s) = 设置的延迟时间(1s) + 代码的执行时间(2s),即:scheduleWithFixedDelay的时间间隔是从上一个任务结束时间来计算的
6、核心方法——shutdown()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void shutdown() {
super.shutdown();
}
//取消并清除由于关闭策略不应该运行的所有任务
@Override void onShutdown() {
BlockingQueue<Runnable> q = super.getQueue();
//获取run-after-shutdown参数
boolean keepDelayed =
getExecuteExistingDelayedTasksAfterShutdownPolicy();
boolean keepPeriodic =
getContinueExistingPeriodicTasksAfterShutdownPolicy();
if (!keepDelayed && !keepPeriodic) {//池关闭后不保留任务
//依次取消任务
for (Object e : q.toArray())
if (e instanceof RunnableScheduledFuture<?>)
((RunnableScheduledFuture<?>) e).cancel(false);
q.clear();//清除等待队列
}
else {//池关闭后保留任务
// Traverse snapshot to avoid iterator exceptions
//遍历快照以避免迭代器异常
for (Object e : q.toArray()) {
if (e instanceof RunnableScheduledFuture) {
RunnableScheduledFuture<?> t =
(RunnableScheduledFuture<?>)e;
if ((t.isPeriodic() ? !keepPeriodic : !keepDelayed) ||
t.isCancelled()) { // also remove if already cancelled
//如果任务已经取消,移除队列中的任务
if (q.remove(t))
t.cancel(false);
}
}
}
}
tryTerminate(); //终止线程池
}

说明:池关闭方法调用了父类ThreadPoolExecutor的shutdown,具体分析见 ThreadPoolExecutor 篇。这里主要介绍以下在shutdown方法中调用的==关闭钩子onShutdown方法==,它的主要作用是在关闭线程池后取消并清除由于关闭策略不应该运行的所有任务,这里主要是根据 run-after-shutdown 参数(continueExistingPeriodicTasksAfterShutdown和executeExistingDelayedTasksAfterShutdown)来决定线程池关闭后是否关闭已经存在的任务

5、再深入理解

1、为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?

例如:

  1. 由于 ScheduledThreadPoolExecutor 是一个固定核心线程数大小的线程池,并且使用了一个无界队列,所以调整maximumPoolSize对其没有任何影响(所以 ScheduledThreadPoolExecutor 没有提供可以调整最大线程数的构造函数,默认最大线程数固定为Integer.MAX_VALUE)。
  2. 此外,设置corePoolSize为0或者设置核心线程空闲后清除(allowCoreThreadTimeOut)同样也不是一个好的策略,因为一旦周期任务到达某一次运行周期时,可能导致线程池内没有线程去处理这些任务。
2、Executors 提供了哪几种方法来构造 ScheduledThreadPoolExecutor?
  • newScheduledThreadPool:可指定核心线程数的线程池。
  • newSingleThreadScheduledExecutor:只有一个工作线程的线程池。如果内部工作线程由于执行周期任务异常而被终止,则会新建一个线程替代它的位置。

注意:newScheduledThreadPool(1, threadFactory) 不等价于newSingleThreadScheduledExecutor。

  • newSingleThreadScheduledExecutor创建的线程池保证内部只有一个线程执行任务,并且线程数不可扩展
  • 而通过newScheduledThreadPool(1, threadFactory)创建的线程池可以通过setCorePoolSize方法来修改核心线程数

6、ScheduledThreadPoolExecutor应用

需求:让每周四 18:00:00 定时执行任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class TestSchedule {

// 如何让每周四 18:00:00 定时执行任务?
public static void main(String[] args) {
// 获取当前时间
LocalDateTime now = LocalDateTime.now();
System.out.println(now);
// 获取周四时间
LocalDateTime time = now.withHour(18).withMinute(0).withSecond(0).withNano(0).with(DayOfWeek.THURSDAY);
// 如果 当前时间 > 本周周四,必须找到下周周四
if(now.compareTo(time) > 0) {
time = time.plusWeeks(1);
}
System.out.println(time);
// initailDelay 代表当前时间和周四的时间差
// period 一周的间隔时间
long initailDelay = Duration.between(now, time).toMillis();
long period = 1000 * 60 * 60 * 24 * 7;
ScheduledExecutorService pool = Executors.newScheduledThreadPool(1);
pool.scheduleAtFixedRate(() -> {
System.out.println("running...");
}, initailDelay, period, TimeUnit.MILLISECONDS);
}
}

19、Tomcat 线程池

1、概述

Tomcat 在哪里用到了线程池呢?——tomcat的连接器部分(Connector)(tomcat还有容器部分——负责servlet规范的)

image-20210812012018371

  • LimitLatch 用来限流,可以控制最大连接个数,类似 J.U.C 中的 Semaphore
  • Acceptor 只负责【接收新的 socket 连接】
  • Poller 只负责监听 socket channel 是否有【可读的 I/O 事件】
  • 一旦可读,封装一个任务对象(socketProcessor),提交给 Executor 线程池处理
  • Executor 线程池中的工作线程最终负责【处理请求】

Tomcat 线程池扩展了 ThreadPoolExecutor,行为稍有不同

  • 如果总线程数达到 maximumPoolSize
    • 这时不会立刻抛 RejectedExecutionException 异常
    • 而是再次尝试将任务放入队列,如果还失败,才抛出 RejectedExecutionException 异常

2、源码 tomcat-7.0.42

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void execute(Runnable command, long timeout, TimeUnit unit) {
submittedCount.incrementAndGet();
try {
// 调用父类的execute方法
super.execute(command);
} catch (RejectedExecutionException rx) { // 出现异常,进行进一步的处理
if (super.getQueue() instanceof TaskQueue) {
// 得到任务队列
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
// 再次尝试将任务放入队列
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
// 失败,抛出异常
throw new RejectedExecutionException("Queue capacity is full.");
}
} catch (InterruptedException x) {
submittedCount.decrementAndGet();
Thread.interrupted();
throw new RejectedExecutionException(x);
}
} else {
submittedCount.decrementAndGet();
throw rx;
}
}
}

TaskQueue.java

1
2
3
4
5
6
7
public boolean force(Runnable  o, long timeout, TimeUnit unit) throws InterruptedException {
if ( parent.isShutdown() )
throw new RejectedExecutionException(
"Executor not running, can't force a command into the queue"
);
return super.offer(o,timeout,unit); //forces the item onto the queue, to be used if the task is rejected
}

3、Connector 配置

配置项 默认值 说明
acceptorThreadCount 1 acceptor 线程数量
pollerThreadCount 1 poller 线程数量
minSpareThreads 10 核心线程数,即 corePoolSize
maxThreads 200 最大线程数,即 maximumPoolSize
executor - Executor 名称,用来引用下面的 Executor

4、Executor 线程配置

配置项 默认值 说明
threadPriority 5 线程优先级
daemon true 是否守护线程
minSpareThreads 25 核心线程数,即 corePoolSize
maxThreads 200 最大线程数,即 maximumPoolSize
maxIdleTime 60000 线程生存时间,单位是毫秒,默认值即 1 分钟
maxQueueSize Integer.MAX_VALUE 队列长度
prestartminSpareThreads false 核心线程是否在服务器启动时启动

20、Fork/Join分支合并框架

ForkJoinPool 是JDK 7加入的一个线程池类。Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。

1、BAT大厂的面试问题

  • Fork/Join主要用来解决什么样的问题?
  • Fork/Join框架是在哪个JDK版本中引入的?
  • Fork/Join框架主要包含哪三个模块?模块之间的关系是怎么样的?
  • ForkJoinPool类继承关系?
  • ForkJoinTask抽象类继承关系?
    • 在实际运用中,我们一般都会继承 RecursiveTaskRecursiveActionCountedCompleter 来实现我们的业务需求,而不会直接继承 ForkJoinTask 类
  • 整个Fork/Join 框架的执行流程/运行机制是怎么样的?
  • 具体阐述Fork/Join的分治思想和work-stealing 实现方式?
  • 有哪些JDK源码中使用了Fork/Join思想?
  • 如何使用Executors工具类创建ForkJoinPool?
  • 写一个例子:用ForkJoin方式实现1+2+3+…+100000?
  • Fork/Join在使用时有哪些注意事项?结合JDK中的斐波那契数列实例具体说明

2、Fork/Join框架简介

Fork/Join框架是Java并发工具包中的一种可以将一个大任务拆分为很多小任务来异步执行的工具,自JDK1.7引入。

1、三个模块及关系

Fork/Join框架主要包含三个模块:

  • 任务对象:ForkJoinTask (包括RecursiveTaskRecursiveActionCountedCompleter)
  • 执行Fork/Join任务的线程:ForkJoinWorkerThread
  • 线程池:ForkJoinPool

这三者的关系是:ForkJoinPool可以通过池中的ForkJoinWorkerThread来处理ForkJoinTask任务。

1
2
3
4
5
6
7
8
9
10
11
// from 《A Java Fork/Join Framework》Dong Lea
Result solve(Problem problem) {
if (problem is small)
directly solve problem
else {
split problem into independent parts
fork new subtasks to solve each part
join all subtasks
compose result from subresults
}
}

ForkJoinPool 只接收 ForkJoinTask 任务(在实际使用中,也可以接收 Runnable/Callable 任务,但在真正运行时,也会把这些任务封装成 ForkJoinTask 类型的任务),RecursiveTask 是 ForkJoinTask 的子类,是一个可以递归执行的 ForkJoinTask,RecursiveAction 是一个无返回值的 RecursiveTask,CountedCompleter 在任务完成执行后会触发执行一个自定义的钩子函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// From JDK 7 doc. Class RecursiveTask<V>
class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) { this.n = n; }
Integer compute() {
if (n <= 1)
return n;
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}

在实际运用中,我们一般都会继承 RecursiveTaskRecursiveActionCountedCompleter 来实现我们的业务需求,而不会直接继承 ForkJoinTask 类。

2、核心思想:分治算法(Divide-and-Conquer)

分治算法(Divide-and-Conquer)把任务递归的拆分为各个子任务,这样可以更好的利用系统资源,尽可能的使用所有可用的计算能力来提升应用性能。首先看一下 Fork/Join 框架的任务运行机制:

img

Fork/Join 框架要完成两件事情:

  • Fork:把一个复杂任务进行分拆,大事化小
  • Join:把分拆任务的结果进行合并

image-20210729225650517

  1. 任务分割:首先 Fork/Join 框架需要把大的任务分割成足够小的子任务,如果子任务比较大的话还要对子任务进行继续分割
  2. 执行任务并合并结果:分割的子任务分别放到双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都放在另外一个队列里,启动一个线程从队列里取数据,然后合并这些数据。
3、核心思想:work-stealing(工作窃取)算法

work-stealing(工作窃取)算法:线程池内的所有工作线程都尝试找到并执行已经提交的任务,或者是被其他活动任务创建的子任务(如果不存在就阻塞等待)。这种特性使得 ForkJoinPool 在运行多个可以产生子任务的任务,或者是提交的许多小任务时效率更高。尤其是构建异步模型的 ForkJoinPool 时,对不需要合并(join)的事件类型任务也非常适用。

在 ForkJoinPool 中,线程池中每个工作线程(ForkJoinWorkerThread)都对应一个任务队列(WorkQueue),工作线程优先处理来自自身队列的任务(LIFO或FIFO顺序,参数 mode 决定),然后以FIFO的顺序随机窃取其他队列中的任务。

具体思路如下:

  • 每个线程都有自己的一个WorkQueue,该工作队列是一个双端队列。
  • 队列支持三个功能push、pop、poll
  • push/pop只能被队列的所有者线程调用,而poll可以被其他线程调用。
  • 划分的子任务调用fork时,都会被push到自己的队列中。
  • 默认情况下,工作线程从自己的双端队列获出任务并执行。
  • 当自己的队列为空时,线程随机从另一个线程的队列末尾调用poll方法窃取任务。

img

4、Fork/Join 框架的执行流程

上图可以看出ForkJoinPool 中的任务执行分两种:

  • 直接通过 FJP 提交的外部任务(external/submissions task),存放在 workQueues 的偶数槽位;
  • 通过内部 fork 分割的子任务(Worker task),存放在 workQueues 的奇数槽位。

那Fork/Join 框架的执行流程是什么样的?

img

3、Fork/Join类关系

1、ForkJoinPool继承关系

img

内部类介绍:

  • ForkJoinWorkerThreadFactory:内部线程工厂接口,用于创建工作线程ForkJoinWorkerThread
  • DefaultForkJoinWorkerThreadFactory:ForkJoinWorkerThreadFactory 的默认实现类
  • InnocuousForkJoinWorkerThreadFactory:实现了 ForkJoinWorkerThreadFactory,无许可线程工厂,当系统变量中有系统安全管理相关属性时,默认使用这个工厂创建工作线程。
  • EmptyTask:内部占位类,用于替换队列中 join 的任务。
  • ManagedBlocker:为 ForkJoinPool 中的任务提供扩展管理并行数的接口,一般用在可能会阻塞的任务(如在 Phaser 中用于等待 phase 到下一个generation)。
  • WorkQueue:ForkJoinPool 的核心数据结构,本质上是work-stealing 模式的双端任务队列,内部存放 ForkJoinTask 对象任务,使用 @Contented 注解修饰防止伪共享
    • 工作线程在运行中产生新的任务(通常是因为调用了 fork())时,此时可以把 WorkQueue 的数据结构视为一个栈,新的任务会放入栈顶(top 位);工作线程在处理自己工作队列的任务时,按照 LIFO 的顺序。
    • 工作线程在处理自己的工作队列同时,会尝试窃取一个任务(可能是来自于刚刚提交到 pool 的任务,或是来自于其他工作线程的队列任务),此时可以把 WorkQueue 的数据结构视为一个 FIFO 的队列,窃取的任务位于其他线程的工作队列的队首(base位)。
  • 伪共享状态:缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
2、ForkJoinTask继承关系

img

ForkJoinTask 实现了 Future 接口,说明它也是一个可取消的异步运算任务,实际上ForkJoinTask 是 Future 的轻量级实现,主要用在纯粹是计算的函数式任务或者操作完全独立的对象计算任务。fork 是主运行方法,用于异步执行;而 join 方法在任务结果计算完毕之后才会运行,用来合并或返回计算结果。 其内部类都比较简单,ExceptionNode 是用于存储任务执行期间的异常信息的单向链表;其余四个类是为 Runnable/Callable 任务提供的适配器类,用于把 Runnable/Callable 转化为 ForkJoinTask 类型的任务(因为 ForkJoinPool 只可以运行 ForkJoinTask 类型的任务)。

4、Fork/Join框架源码解析

分析思路:在对类层次结构有了解以后,我们先看下内部核心参数,然后分析上述流程图。会分4个部分:

  • 首先介绍任务的提交流程 - 外部任务(external/submissions task)提交;
  • 然后介绍任务的提交流程 - 子任务(Worker task)提交;
  • 再分析任务的执行过程(ForkJoinWorkerThread.run()到ForkJoinTask.doExec()这一部分);
  • 最后介绍任务的结果获取(ForkJoinTask.join()和ForkJoinTask.invoke())
1、ForkJoinPool
1、核心参数

在后面的源码解析中,我们会看到大量的位运算,这些位运算都是通过我们接下来介绍的一些常量参数来计算的。

例如,如果要更新活跃线程数,使用公式(UC_MASK & (c + AC_UNIT)) | (SP_MASK & c);c 代表当前 ctl,UC_MASK 和 SP_MASK 分别是高位和低位掩码,AC_UNIT 为活跃线程的增量数,使用(UC_MASK & (c + AC_UNIT))就可以计算出高32位,然后再加上低32位(SP_MASK & c),就拼接成了一个新的ctl。

这些运算的可读性很差,看起来有些复杂。在后面源码解析中有位运算的地方我都会加上注释,大家只需要了解它们的作用即可。

ForkJoinPool 与 内部类 WorkQueue 共享的一些常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Constants shared across ForkJoinPool and WorkQueue

// 限定参数
static final int SMASK = 0xffff; // 低位掩码,也是最大索引位
static final int MAX_CAP = 0x7fff; // 工作线程最大容量
static final int EVENMASK = 0xfffe; // 偶数低位掩码
static final int SQMASK = 0x007e; // workQueues 数组最多64个槽位

// ctl 子域和 WorkQueue.scanState 的掩码和标志位
static final int SCANNING = 1; // 标记是否正在运行任务
static final int INACTIVE = 1 << 31; // 失活状态 负数
static final int SS_SEQ = 1 << 16; // 版本戳,防止ABA问题

// ForkJoinPool.config 和 WorkQueue.config 的配置信息标记
static final int MODE_MASK = 0xffff << 16; // 模式掩码
static final int LIFO_QUEUE = 0; //LIFO队列
static final int FIFO_QUEUE = 1 << 16;//FIFO队列
static final int SHARED_QUEUE = 1 << 31; // 共享模式队列,负数

ForkJoinPool 中的相关常量和实例字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//  低位和高位掩码
private static final long SP_MASK = 0xffffffffL;
private static final long UC_MASK = ~SP_MASK;

// 活跃线程数
private static final int AC_SHIFT = 48;
private static final long AC_UNIT = 0x0001L << AC_SHIFT; //活跃线程数增量
private static final long AC_MASK = 0xffffL << AC_SHIFT; //活跃线程数掩码

// 工作线程数
private static final int TC_SHIFT = 32;
private static final long TC_UNIT = 0x0001L << TC_SHIFT; //工作线程数增量
private static final long TC_MASK = 0xffffL << TC_SHIFT; //掩码
private static final long ADD_WORKER = 0x0001L << (TC_SHIFT + 15); // 创建工作线程标志

// 池状态
private static final int RSLOCK = 1;
private static final int RSIGNAL = 1 << 1;
private static final int STARTED = 1 << 2;
private static final int STOP = 1 << 29;
private static final int TERMINATED = 1 << 30;
private static final int SHUTDOWN = 1 << 31;

// 实例字段
volatile long ctl; // 主控制参数
volatile int runState; // 运行状态锁
final int config; // 并行度|模式
int indexSeed; // 用于生成工作线程索引
volatile WorkQueue[] workQueues; // 主对象注册信息,workQueue
final ForkJoinWorkerThreadFactory factory;// 线程工厂
final UncaughtExceptionHandler ueh; // 每个工作线程的异常信息
final String workerNamePrefix; // 用于创建工作线程的名称
volatile AtomicLong stealCounter; // 偷取任务总数,也可作为同步监视器

/** 静态初始化字段 */
//线程工厂
public static final ForkJoinWorkerThreadFactory defaultForkJoinWorkerThreadFactory;
//启动或杀死线程的方法调用者的权限
private static final RuntimePermission modifyThreadPermission;
// 公共静态pool
static final ForkJoinPool common;
//并行度,对应内部common池
static final int commonParallelism;
//备用线程数,在tryCompensate中使用
private static int commonMaxSpares;
//创建workerNamePrefix(工作线程名称前缀)时的序号
private static int poolNumberSequence;
//线程阻塞等待新的任务的超时值(以纳秒为单位),默认2秒
private static final long IDLE_TIMEOUT = 2000L * 1000L * 1000L; // 2sec
//空闲超时时间,防止timer未命中
private static final long TIMEOUT_SLOP = 20L * 1000L * 1000L; // 20ms
//默认备用线程数
private static final int DEFAULT_COMMON_MAX_SPARES = 256;
//阻塞前自旋的次数,用在在awaitRunStateLock和awaitWork中
private static final int SPINS = 0;
//indexSeed的增量
private static final int SEED_INCREMENT = 0x9e3779b9;

说明:ForkJoinPool 的内部状态都是通过一个64位的 long 型 变量ctl来存储,它由四个16位的子域组成:

  • AC:正在运行工作线程数减去目标并行度,高16位
  • TC:总工作线程数减去目标并行度,中高16位
  • SS:栈顶等待线程的版本计数和状态,中低16位
  • ID:栈顶 WorkQueue 在池中的索引(poolIndex),低16位

在后面的源码解析中,某些地方也提取了ctl的低32位(sp=(int)ctl)来检查工作线程状态,例如,当sp不为0时说明当前还有空闲工作线程。

2、ForkJoinPool.WoekQueue 中的相关属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//初始队列容量,2的幂
static final int INITIAL_QUEUE_CAPACITY = 1 << 13;
//最大队列容量
static final int MAXIMUM_QUEUE_CAPACITY = 1 << 26; // 64M

// 实例字段
volatile int scanState; // Woker状态, <0: inactive; odd:scanning
int stackPred; // 记录前一个栈顶的ctl
int nsteals; // 偷取任务数
int hint; // 记录偷取者索引,初始为随机索引
int config; // 池索引和模式
volatile int qlock; // 1: locked, < 0: terminate; else 0
volatile int base; // 下一个poll操作的索引(栈底/队列头)
int top; // 下一个push操作的索引(栈顶/队列尾)
ForkJoinTask<?>[] array; // 任务数组
final ForkJoinPool pool; // the containing pool (may be null)
final ForkJoinWorkerThread owner; // 当前工作队列的工作线程,共享模式下为null
volatile Thread parker; // 调用park阻塞期间为owner,其他情况为null
volatile ForkJoinTask<?> currentJoin; // 记录被join过来的任务
volatile ForkJoinTask<?> currentSteal; // 记录从其他工作队列偷取过来的任务
2、ForkJoinTask
核心参数
1
2
3
4
5
6
7
8
/** 任务运行状态 */
volatile int status; // 任务运行状态
static final int DONE_MASK = 0xf0000000; // 任务完成状态标志位
static final int NORMAL = 0xf0000000; // must be negative
static final int CANCELLED = 0xc0000000; // must be < NORMAL
static final int EXCEPTIONAL = 0x80000000; // must be < CANCELLED
static final int SIGNAL = 0x00010000; // must be >= 1 << 16 等待信号
static final int SMASK = 0x0000ffff; // 低位掩码

5、Fork/Join框架源码解析

1、构造函数
1
2
3
4
5
6
7
8
9
10
11
public ForkJoinPool(int parallelism,
ForkJoinWorkerThreadFactory factory,
UncaughtExceptionHandler handler,
boolean asyncMode) {
this(checkParallelism(parallelism),
checkFactory(factory),
handler,
asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
"ForkJoinPool-" + nextPoolId() + "-worker-");
checkPermission();
}

说明:在 ForkJoinPool 中我们可以自定义四个参数:

  • parallelism:并行度,默认为CPU数,最小为1
  • factory:工作线程工厂;
  • handler:处理工作线程运行任务时的异常情况类,默认为null;
  • asyncMode:是否为异步模式,默认为 false。如果为true,表示子任务的执行遵循 FIFO 顺序并且任务不能被合并(join),这种模式适用于工作线程只运行事件类型的异步任务

在多数场景使用时,如果没有太强的业务需求,我们一般直接使用 ForkJoinPool 中的common池,在JDK1.8之后提供了ForkJoinPool.commonPool()方法可以直接使用common池,来看一下它的构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static ForkJoinPool makeCommonPool() {
int parallelism = -1;
ForkJoinWorkerThreadFactory factory = null;
UncaughtExceptionHandler handler = null;
try { // ignore exceptions in accessing/parsing
String pp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.parallelism");//并行度
String fp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.threadFactory");//线程工厂
String hp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.exceptionHandler");//异常处理类
if (pp != null)
parallelism = Integer.parseInt(pp);
if (fp != null)
factory = ((ForkJoinWorkerThreadFactory) ClassLoader.
getSystemClassLoader().loadClass(fp).newInstance());
if (hp != null)
handler = ((UncaughtExceptionHandler) ClassLoader.
getSystemClassLoader().loadClass(hp).newInstance());
} catch (Exception ignore) {
}
if (factory == null) {
if (System.getSecurityManager() == null)
factory = defaultForkJoinWorkerThreadFactory;
else // use security-managed default
factory = new InnocuousForkJoinWorkerThreadFactory();
}
if (parallelism < 0 && // default 1 less than #cores
(parallelism = Runtime.getRuntime().availableProcessors() - 1) <= 0)
parallelism = 1;//默认并行度为1
if (parallelism > MAX_CAP)
parallelism = MAX_CAP;
return new ForkJoinPool(parallelism, factory, handler, LIFO_QUEUE,
"ForkJoinPool.commonPool-worker-");
}

使用common pool的优点就是我们可以通过指定系统参数的方式定义“并行度、线程工厂和异常处理类”;并且它使用的是同步模式,也就是说可以支持任务合并(join)。

2、执行流程——外部任务(external/submissions task)提交

向 ForkJoinPool 提交任务有三种方式:

  • invoke()会等待任务计算完毕并返回计算结果;
  • execute()是直接向池提交一个任务来异步执行,无返回结果;
  • submit()也是异步执行,但是会返回提交的任务,在适当的时候可通过task.get()获取执行结果。

这三种提交方式都都是调用externalPush()方法来完成,所以接下来我们将从externalPush()方法开始逐步分析外部任务的执行过程。

1、externalPush(ForkJoinTask<?> task)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//添加给定任务到submission队列中
final void externalPush(ForkJoinTask<?> task) {
WorkQueue[] ws;
WorkQueue q;
int m;
int r = ThreadLocalRandom.getProbe();//探针值,用于计算WorkQueue槽位索引
int rs = runState;
if ((ws = workQueues) != null && (m = (ws.length - 1)) >= 0 &&
(q = ws[m & r & SQMASK]) != null && r != 0 && rs > 0 && //获取随机偶数槽位的workQueue
U.compareAndSwapInt(q, QLOCK, 0, 1)) {//锁定workQueue
ForkJoinTask<?>[] a;
int am, n, s;
if ((a = q.array) != null &&
(am = a.length - 1) > (n = (s = q.top) - q.base)) {
int j = ((am & s) << ASHIFT) + ABASE;//计算任务索引位置
U.putOrderedObject(a, j, task);//任务入列
U.putOrderedInt(q, QTOP, s + 1);//更新push slot
U.putIntVolatile(q, QLOCK, 0);//解除锁定
if (n <= 1)
signalWork(ws, q);//任务数小于1时尝试创建或激活一个工作线程
return;
}
U.compareAndSwapInt(q, QLOCK, 1, 0);//解除锁定
}
externalSubmit(task);//初始化workQueues及相关属性
}

首先说明一下externalPush和externalSubmit两个方法的联系:它们的作用都是把任务放到队列中等待执行。不同的是,externalSubmit可以说是完整版的externalPush,在任务首次提交时,需要初始化workQueues及其他相关属性,这个初始化操作就是externalSubmit来完成的;而后再向池中提交的任务都是通过简化版的externalSubmit-externalPush来完成。

externalPush的执行流程很简单:

  1. 首先找到一个随机偶数槽位的 workQueue,
  2. 然后把任务放入这个 workQueue 的任务数组中,并更新top位。
  3. 如果队列的剩余任务数小于1,则尝试创建或激活一个工作线程来运行任务(防止在externalSubmit初始化时发生异常导致工作线程创建失败)。
2、externalSubmit(ForkJoinTask<?> task)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
//任务提交
private void externalSubmit(ForkJoinTask<?> task) {
//初始化调用线程的探针值,用于计算WorkQueue索引
int r; // initialize caller's probe
if ((r = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit();
r = ThreadLocalRandom.getProbe();
}
for (; ; ) {
WorkQueue[] ws;
WorkQueue q;
int rs, m, k;
boolean move = false;
if ((rs = runState) < 0) {// 池已关闭
tryTerminate(false, false); // help terminate
throw new RejectedExecutionException();
}
//初始化workQueues
else if ((rs & STARTED) == 0 || // initialize
((ws = workQueues) == null || (m = ws.length - 1) < 0)) {
int ns = 0;
rs = lockRunState();//锁定runState
try {
//初始化
if ((rs & STARTED) == 0) {
//初始化stealCounter
U.compareAndSwapObject(this, STEALCOUNTER, null,
new AtomicLong());
//创建workQueues,容量为2的幂次方
// create workQueues array with size a power of two
int p = config & SMASK; // ensure at least 2 slots
int n = (p > 1) ? p - 1 : 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
n = (n + 1) << 1;
workQueues = new WorkQueue[n];
ns = STARTED;
}
} finally {
unlockRunState(rs, (rs & ~RSLOCK) | ns);//解锁并更新runState
}
} else if ((q = ws[k = r & m & SQMASK]) != null) {//获取随机偶数槽位的workQueue
if (q.qlock == 0 && U.compareAndSwapInt(q, QLOCK, 0, 1)) {//锁定 workQueue
ForkJoinTask<?>[] a = q.array;//当前workQueue的全部任务
int s = q.top;
boolean submitted = false; // initial submission or resizing
try { // locked version of push
if ((a != null && a.length > s + 1 - q.base) ||
(a = q.growArray()) != null) {//扩容
int j = (((a.length - 1) & s) << ASHIFT) + ABASE;
U.putOrderedObject(a, j, task);//放入给定任务
U.putOrderedInt(q, QTOP, s + 1);//修改push slot
submitted = true;
}
} finally {
U.compareAndSwapInt(q, QLOCK, 1, 0);//解除锁定
}
if (submitted) {//任务提交成功,创建或激活工作线程
signalWork(ws, q);//创建或激活一个工作线程来运行任务
return;
}
}
move = true; // move on failure 操作失败,重新获取探针值
} else if (((rs = runState) & RSLOCK) == 0) { // create new queue
q = new WorkQueue(this, null);
q.hint = r;
q.config = k | SHARED_QUEUE;
q.scanState = INACTIVE;
rs = lockRunState(); // publish index
if (rs > 0 && (ws = workQueues) != null &&
k < ws.length && ws[k] == null)
ws[k] = q; // 更新索引k位值的workQueue
//else terminated
unlockRunState(rs, rs & ~RSLOCK);
} else
move = true; // move if busy
if (move)
r = ThreadLocalRandom.advanceProbe(r);//重新获取线程探针值
}
}

说明:externalSubmit是externalPush的完整版本,主要用于第一次提交任务时初始化workQueues及相关属性,并且提交给定任务到队列中。具体执行步骤如下:

  • 如果池为终止状态(runState<0),调用tryTerminate来终止线程池,并抛出任务拒绝异常;
  • 如果尚未初始化,就为 FJP 执行初始化操作:初始化stealCounter、创建workerQueues,然后继续自旋;
  • 初始化完成后,执行在externalPush中相同的操作:获取 workQueue,放入指定任务。任务提交成功后调用signalWork方法创建或激活线程;
  • 如果在步骤3中获取到的 workQueue 为null,会在这一步中创建一个 workQueue,创建成功继续自旋执行第三步操作;
  • 如果非上述情况,或者有线程争用资源导致获取锁失败,就重新获取线程探针值继续自旋。
3、signalWork(WorkQueue[] ws, WorkQueue q)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
final void signalWork(WorkQueue[] ws, WorkQueue q) {
long c;
int sp, i;
WorkQueue v;
Thread p;
while ((c = ctl) < 0L) { // too few active
if ((sp = (int) c) == 0) { // no idle workers
if ((c & ADD_WORKER) != 0L) // too few workers
tryAddWorker(c);//工作线程太少,添加新的工作线程
break;
}
if (ws == null) // unstarted/terminated
break;
if (ws.length <= (i = sp & SMASK)) // terminated
break;
if ((v = ws[i]) == null) // terminating
break;
//计算ctl,加上版本戳SS_SEQ避免ABA问题
int vs = (sp + SS_SEQ) & ~INACTIVE; // next scanState
int d = sp - v.scanState; // screen CAS
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
long nc = (UC_MASK & (c + AC_UNIT)) | (SP_MASK & v.stackPred);
if (d == 0 && U.compareAndSwapLong(this, CTL, c, nc)) {
v.scanState = vs; // activate v
if ((p = v.parker) != null)
U.unpark(p);//唤醒阻塞线程
break;
}
if (q != null && q.base == q.top) // no more work
break;
}
}

说明:新建或唤醒一个工作线程,在externalPushexternalSubmitworkQueue.pushscan中调用。如果还有空闲线程,则尝试唤醒索引到的 WorkQueue 的parker线程;如果工作线程过少((ctl & ADD_WORKER) != 0L),则调用tryAddWorker添加一个新的工作线程。

4、tryAddWorker(long c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void tryAddWorker(long c) {
boolean add = false;
do {
long nc = ((AC_MASK & (c + AC_UNIT)) |
(TC_MASK & (c + TC_UNIT)));
if (ctl == c) {
int rs, stop; // check if terminating
if ((stop = (rs = lockRunState()) & STOP) == 0)
add = U.compareAndSwapLong(this, CTL, c, nc);
unlockRunState(rs, rs & ~RSLOCK);//释放锁
if (stop != 0)
break;
if (add) {
createWorker();//创建工作线程
break;
}
}
} while (((c = ctl) & ADD_WORKER) != 0L && (int)c == 0);
}

说明:尝试添加一个新的工作线程,首先更新ctl中的工作线程数,然后调用createWorker()创建工作线程。

5、createWorker()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private boolean createWorker() {
ForkJoinWorkerThreadFactory fac = factory;
Throwable ex = null;
ForkJoinWorkerThread wt = null;
try {
if (fac != null && (wt = fac.newThread(this)) != null) {
wt.start();
return true;
}
} catch (Throwable rex) {
ex = rex;
}
deregisterWorker(wt, ex);//线程创建失败处理
return false;
}

说明:createWorker首先通过线程工厂创一个新的ForkJoinWorkerThread,然后启动这个工作线程(wt.start())。如果期间发生异常,调用deregisterWorker处理线程创建失败的逻辑(deregisterWorker在后面再详细说明)。

ForkJoinWorkerThread 的构造函数如下:

1
2
3
4
5
6
protected ForkJoinWorkerThread(ForkJoinPool pool) {
// Use a placeholder until a useful name can be set in registerWorker
super("aForkJoinWorkerThread");
this.pool = pool;
this.workQueue = pool.registerWorker(this);
}

可以看到 ForkJoinWorkerThread 在构造时首先调用父类 Thread 的方法,然后为工作线程注册pool和workQueue,而workQueue的注册任务由ForkJoinPool.registerWorker来完成。

6、registerWorker()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
final WorkQueue registerWorker(ForkJoinWorkerThread wt) {
UncaughtExceptionHandler handler;
//设置为守护线程
wt.setDaemon(true); // configure thread
if ((handler = ueh) != null)
wt.setUncaughtExceptionHandler(handler);
WorkQueue w = new WorkQueue(this, wt);//构造新的WorkQueue
int i = 0; // assign a pool index
int mode = config & MODE_MASK;
int rs = lockRunState();
try {
WorkQueue[] ws;
int n; // skip if no array
if ((ws = workQueues) != null && (n = ws.length) > 0) {
//生成新建WorkQueue的索引
int s = indexSeed += SEED_INCREMENT; // unlikely to collide
int m = n - 1;
i = ((s << 1) | 1) & m; // Worker任务放在奇数索引位 odd-numbered indices
if (ws[i] != null) { // collision 已存在,重新计算索引位
int probes = 0; // step by approx half n
int step = (n <= 4) ? 2 : ((n >>> 1) & EVENMASK) + 2;
//查找可用的索引位
while (ws[i = (i + step) & m] != null) {
if (++probes >= n) {//所有索引位都被占用,对workQueues进行扩容
workQueues = ws = Arrays.copyOf(ws, n <<= 1);//workQueues 扩容
m = n - 1;
probes = 0;
}
}
}
w.hint = s; // use as random seed
w.config = i | mode;
w.scanState = i; // publication fence
ws[i] = w;
}
} finally {
unlockRunState(rs, rs & ~RSLOCK);
}
wt.setName(workerNamePrefix.concat(Integer.toString(i >>> 1)));
return w;
}

说明:registerWorker是 ForkJoinWorkerThread 构造器的回调函数,用于创建和记录工作线程的 WorkQueue。比较简单,就不多赘述了。注意在此为工作线程创建的 WorkQueue 是放在奇数索引的(代码行: i = ((s << 1) | 1) & m;)

7、小结

OK,外部任务的提交流程就先讲到这里。在createWorker()中启动工作线程后(wt.start()),当为线程分配到CPU执行时间片之后会运行 ForkJoinWorkerThread 的run方法开启线程来执行任务。工作线程执行任务的流程我们在讲完内部任务提交之后会统一讲解。

3、执行流程:子任务(Worker task)提交

子任务的提交相对比较简单,由任务的fork()方法完成。通过上面的流程图可以看到任务被分割(fork)之后调用了ForkJoinPool.WorkQueue.push()方法直接把任务放到队列中等待被执行。

1、ForkJoinTask.fork()
1
2
3
4
5
6
7
8
public final ForkJoinTask<V> fork() {
Thread t;
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
((ForkJoinWorkerThread)t).workQueue.push(this);
else
ForkJoinPool.common.externalPush(this);
return this;
}

说明:如果当前线程是 Worker 线程,说明当前任务是fork分割的子任务,通过ForkJoinPool.workQueue.push()方法直接把任务放到自己的等待队列中;否则调用ForkJoinPool.externalPush()提交到一个随机的等待队列中(外部任务)。

2、ForkJoiPool.WorkQueue.push()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final void push(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a;
ForkJoinPool p;
int b = base, s = top, n;
if ((a = array) != null) { // ignore if queue removed
int m = a.length - 1; // fenced write for task visibility
U.putOrderedObject(a, ((m & s) << ASHIFT) + ABASE, task);
U.putOrderedInt(this, QTOP, s + 1);
if ((n = s - b) <= 1) {//首次提交,创建或唤醒一个工作线程
if ((p = pool) != null)
p.signalWork(p.workQueues, this);
} else if (n >= m)
growArray();
}
}

说明:首先把任务放入等待队列并更新top位;如果当前 WorkQueue 为新建的等待队列(top-base<=1),则调用signalWork方法为当前 WorkQueue 新建或唤醒一个工作线程;如果 WorkQueue 中的任务数组容量过小,则调用growArray()方法对其进行==两倍==扩容,growArray()方法源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
final ForkJoinTask<?>[] growArray() {
ForkJoinTask<?>[] oldA = array;//获取内部任务列表
int size = oldA != null ? oldA.length << 1 : INITIAL_QUEUE_CAPACITY;
if (size > MAXIMUM_QUEUE_CAPACITY)
throw new RejectedExecutionException("Queue capacity exceeded");
int oldMask, t, b;
//新建一个两倍容量的任务数组
ForkJoinTask<?>[] a = array = new ForkJoinTask<?>[size];
if (oldA != null && (oldMask = oldA.length - 1) >= 0 &&
(t = top) - (b = base) > 0) {
int mask = size - 1;
//从老数组中拿出数据,放到新的数组中
do { // emulate poll from old array, push to new array
ForkJoinTask<?> x;
int oldj = ((b & oldMask) << ASHIFT) + ABASE;
int j = ((b & mask) << ASHIFT) + ABASE;
x = (ForkJoinTask<?>) U.getObjectVolatile(oldA, oldj);
if (x != null &&
U.compareAndSwapObject(oldA, oldj, x, null))
U.putObjectVolatile(a, j, x);
} while (++b != t);
}
return a;
}
3、小结

到此,两种任务的提交流程都已经解析完毕,下一节我们来一起看看任务提交之后是如何被运行的。

4、执行流程:任务执行

回到我们开始时的流程图,在ForkJoinPool .createWorker()方法中创建工作线程后,会启动工作线程,系统为工作线程分配到CPU执行时间片之后会执行 ForkJoinWorkerThread 的run()方法正式开始执行任务。

1、ForkJoinWorkerThread.run()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void run() {
if (workQueue.array == null) { // only run once
Throwable exception = null;
try {
onStart();//钩子方法,可自定义扩展
pool.runWorker(workQueue);
} catch (Throwable ex) {
exception = ex;
} finally {
try {
onTermination(exception);//钩子方法,可自定义扩展
} catch (Throwable ex) {
if (exception == null)
exception = ex;
} finally {
pool.deregisterWorker(this, exception);//处理异常
}
}
}
}

说明:方法很简单,在工作线程运行前后会调用自定义钩子函数(onStartonTermination),任务的运行则是调用了ForkJoinPool.runWorker()。如果全部任务执行完毕或者期间遭遇异常,则通过ForkJoinPool.deregisterWorker关闭工作线程并处理异常信息(deregisterWorker方法我们后面会详细讲解)。

2、ForkJoinPool.runWorker(WorkerQueue w)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
final void runWorker(WorkQueue w) {
w.growArray(); // allocate queue
int seed = w.hint; // initially holds randomization hint
int r = (seed == 0) ? 1 : seed; // avoid 0 for xorShift
for (ForkJoinTask<?> t; ; ) {
if ((t = scan(w, r)) != null)//扫描任务执行
w.runTask(t);
else if (!awaitWork(w, r))
break;
r ^= r << 13;
r ^= r >>> 17;
r ^= r << 5; // xorshift
}
}

说明:runWorker是 ForkJoinWorkerThread 的主运行方法,用来依次执行当前工作线程中的任务。

函数流程很简单:调用scan方法依次获取任务,然后调用WorkQueue .runTask运行任务;如果未扫描到任务,则调用awaitWork等待,直到工作线程/线程池终止或等待超时。

3、ForkJoinPool.scan(WorkQueue w, int r)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
private ForkJoinTask<?> scan(WorkQueue w, int r) {
WorkQueue[] ws;
int m;
if ((ws = workQueues) != null && (m = ws.length - 1) > 0 && w != null) {
int ss = w.scanState; // initially non-negative
//初始扫描起点,自旋扫描
for (int origin = r & m, k = origin, oldSum = 0, checkSum = 0; ; ) {
WorkQueue q;
ForkJoinTask<?>[] a;
ForkJoinTask<?> t;
int b, n;
long c;
if ((q = ws[k]) != null) {//获取workQueue
if ((n = (b = q.base) - q.top) < 0 &&
(a = q.array) != null) { // non-empty
//计算偏移量
long i = (((a.length - 1) & b) << ASHIFT) + ABASE;
if ((t = ((ForkJoinTask<?>)
U.getObjectVolatile(a, i))) != null && //取base位置任务
q.base == b) {//stable
if (ss >= 0) { //scanning
if (U.compareAndSwapObject(a, i, t, null)) {//
q.base = b + 1;//更新base位
if (n < -1) // signal others
signalWork(ws, q);//创建或唤醒工作线程来运行任务
return t;
}
} else if (oldSum == 0 && // try to activate 尝试激活工作线程
w.scanState < 0)
tryRelease(c = ctl, ws[m & (int) c], AC_UNIT);//唤醒栈顶工作线程
}
//base位置任务为空或base位置偏移,随机移位重新扫描
if (ss < 0) // refresh
ss = w.scanState;
r ^= r << 1;
r ^= r >>> 3;
r ^= r << 10;
origin = k = r & m; // move and rescan
oldSum = checkSum = 0;
continue;
}
checkSum += b;//队列任务为空,记录base位
}
//更新索引k 继续向后查找
if ((k = (k + 1) & m) == origin) { // continue until stable
//运行到这里说明已经扫描了全部的 workQueues,但并未扫描到任务

if ((ss >= 0 || (ss == (ss = w.scanState))) &&
oldSum == (oldSum = checkSum)) {
if (ss < 0 || w.qlock < 0) // already inactive
break;// 已经被灭活或终止,跳出循环

//对当前WorkQueue进行灭活操作
int ns = ss | INACTIVE; // try to inactivate
long nc = ((SP_MASK & ns) |
(UC_MASK & ((c = ctl) - AC_UNIT)));//计算ctl为INACTIVE状态并减少活跃线程数
w.stackPred = (int) c; // hold prev stack top
U.putInt(w, QSCANSTATE, ns);//修改scanState为inactive状态
if (U.compareAndSwapLong(this, CTL, c, nc))//更新scanState为灭活状态
ss = ns;
else
w.scanState = ss; // back out
}
checkSum = 0;//重置checkSum,继续循环
}
}
}
return null;
}

说明:扫描并尝试偷取一个任务。使用w.hint进行随机索引 WorkQueue,也就是说并不一定会执行当前 WorkQueue 中的任务,而是偷取别的Worker的任务来执行。

函数的大概执行流程如下:

  • 取随机位置的一个 WorkQueue;
  • 获取base位的 ForkJoinTask,成功取到后更新base位并返回任务;如果取到的 WorkQueue 中任务数大于1,则调用signalWork创建或唤醒其他工作线程;
  • 如果当前工作线程处于不活跃状态(INACTIVE),则调用tryRelease尝试唤醒栈顶工作线程来执行。

tryRelease源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private boolean tryRelease(long c, WorkQueue v, long inc) {
int sp = (int) c, vs = (sp + SS_SEQ) & ~INACTIVE;
Thread p;
//ctl低32位等于scanState,说明可以唤醒parker线程
if (v != null && v.scanState == sp) { // v is at top of stack
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
long nc = (UC_MASK & (c + inc)) | (SP_MASK & v.stackPred);
if (U.compareAndSwapLong(this, CTL, c, nc)) {
v.scanState = vs;
if ((p = v.parker) != null)
U.unpark(p);//唤醒线程
return true;
}
}
return false;
}
  • 如果base位任务为空或发生偏移,则对索引位进行随机移位,然后重新扫描;
  • 如果扫描整个workQueues之后没有获取到任务,则设置当前工作线程为INACTIVE状态;然后重置checkSum,再次扫描一圈之后如果还没有任务则跳出循环返回null。
4、ForkJoinPool.awaitWork(WorkQueue w, int r)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
private boolean awaitWork(WorkQueue w, int r) {
if (w == null || w.qlock < 0) // w is terminating
return false;
for (int pred = w.stackPred, spins = SPINS, ss; ; ) {
if ((ss = w.scanState) >= 0)//正在扫描,跳出循环
break;
else if (spins > 0) {
r ^= r << 6;
r ^= r >>> 21;
r ^= r << 7;
if (r >= 0 && --spins == 0) { // randomize spins
WorkQueue v;
WorkQueue[] ws;
int s, j;
AtomicLong sc;
if (pred != 0 && (ws = workQueues) != null &&
(j = pred & SMASK) < ws.length &&
(v = ws[j]) != null && // see if pred parking
(v.parker == null || v.scanState >= 0))
spins = SPINS; // continue spinning
}
} else if (w.qlock < 0) // 当前workQueue已经终止,返回false recheck after spins
return false;
else if (!Thread.interrupted()) {//判断线程是否被中断,并清除中断状态
long c, prevctl, parkTime, deadline;
int ac = (int) ((c = ctl) >> AC_SHIFT) + (config & SMASK);//活跃线程数
if ((ac <= 0 && tryTerminate(false, false)) || //无active线程,尝试终止
(runState & STOP) != 0) // pool terminating
return false;
if (ac <= 0 && ss == (int) c) { // is last waiter
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
prevctl = (UC_MASK & (c + AC_UNIT)) | (SP_MASK & pred);
int t = (short) (c >>> TC_SHIFT); // shrink excess spares
if (t > 2 && U.compareAndSwapLong(this, CTL, c, prevctl))//总线程过量
return false; // else use timed wait
//计算空闲超时时间
parkTime = IDLE_TIMEOUT * ((t >= 0) ? 1 : 1 - t);
deadline = System.nanoTime() + parkTime - TIMEOUT_SLOP;
} else
prevctl = parkTime = deadline = 0L;
Thread wt = Thread.currentThread();
U.putObject(wt, PARKBLOCKER, this); // emulate LockSupport
w.parker = wt;//设置parker,准备阻塞
if (w.scanState < 0 && ctl == c) // recheck before park
U.park(false, parkTime);//阻塞指定的时间

U.putOrderedObject(w, QPARKER, null);
U.putObject(wt, PARKBLOCKER, null);
if (w.scanState >= 0)//正在扫描,说明等到任务,跳出循环
break;
if (parkTime != 0L && ctl == c &&
deadline - System.nanoTime() <= 0L &&
U.compareAndSwapLong(this, CTL, c, prevctl))//未等到任务,更新ctl,返回false
return false; // shrink pool
}
}
return true;
}

说明:回到runWorker方法,如果scan方法未扫描到任务,会调用awaitWork等待获取任务。函数的具体执行流程大家看源码,这里简单说一下:

  • 在等待获取任务期间,如果工作线程或线程池已经终止则直接返回false。
  • 如果当前无 active 线程,尝试终止线程池并返回false,如果终止失败并且当前是最后一个等待的 Worker,就阻塞指定的时间(IDLE_TIMEOUT);
  • 等到届期或被唤醒后如果发现自己是scanning(scanState >= 0)状态,说明已经等到任务,跳出等待返回true继续 scan,否则的更新ctl并返回false。
5、WorkQueue.runTask()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
final void runTask(ForkJoinTask<?> task) {
if (task != null) {
scanState &= ~SCANNING; // mark as busy
(currentSteal = task).doExec();//更新currentSteal并执行任务
U.putOrderedObject(this, QCURRENTSTEAL, null); // release for GC
execLocalTasks();//依次执行本地任务
ForkJoinWorkerThread thread = owner;
if (++nsteals < 0) // collect on overflow
transferStealCount(pool);//增加偷取任务数
scanState |= SCANNING;
if (thread != null)
thread.afterTopLevelExec();//执行钩子函数
}
}

说明:在scan方法扫描到任务之后,调用WorkQueue.runTask()来执行获取到的任务,大概流程如下:

  • 标记scanState为正在执行状态;
  • 更新currentSteal为当前获取到的任务并执行它,任务的执行调用了ForkJoinTask.doExec()方法,

ForkJoinTask.doExec()方法的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//ForkJoinTask.doExec()
final int doExec() {
int s; boolean completed;
if ((s = status) >= 0) {
try {
completed = exec();//执行我们定义的任务
} catch (Throwable rex) {
return setExceptionalCompletion(rex);
}
if (completed)
s = setCompletion(NORMAL);
}
return s;
}

调用execLocalTasks依次执行当前WorkerQueue中的任务,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//执行并移除所有本地任务
final void execLocalTasks() {
int b = base, m, s;
ForkJoinTask<?>[] a = array;
if (b - (s = top - 1) <= 0 && a != null &&
(m = a.length - 1) >= 0) {
if ((config & FIFO_QUEUE) == 0) {//FIFO模式
for (ForkJoinTask<?> t; ; ) {
if ((t = (ForkJoinTask<?>) U.getAndSetObject
(a, ((m & s) << ASHIFT) + ABASE, null)) == null)//FIFO执行,取top任务
break;
U.putOrderedInt(this, QTOP, s);
t.doExec();//执行
if (base - (s = top - 1) > 0)
break;
}
} else
pollAndExecAll();//LIFO模式执行,取base任务
}
}
  • 更新偷取任务数;
  • 还原scanState并执行钩子函数。
6、ForkJoinPool.deregisterWorker(ForkJoinWorkerThread wt, Throwable ex)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
final void deregisterWorker(ForkJoinWorkerThread wt, Throwable ex) {
WorkQueue w = null;
//1.移除workQueue
if (wt != null && (w = wt.workQueue) != null) {//获取ForkJoinWorkerThread的等待队列
WorkQueue[] ws; // remove index from array
int idx = w.config & SMASK;//计算workQueue索引
int rs = lockRunState();//获取runState锁和当前池运行状态
if ((ws = workQueues) != null && ws.length > idx && ws[idx] == w)
ws[idx] = null;//移除workQueue
unlockRunState(rs, rs & ~RSLOCK);//解除runState锁
}
//2.减少CTL数
long c; // decrement counts
do {} while (!U.compareAndSwapLong
(this, CTL, c = ctl, ((AC_MASK & (c - AC_UNIT)) |
(TC_MASK & (c - TC_UNIT)) |
(SP_MASK & c))));
//3.处理被移除workQueue内部相关参数
if (w != null) {
w.qlock = -1; // ensure set
w.transferStealCount(this);
w.cancelAll(); // cancel remaining tasks
}
//4.如果线程未终止,替换被移除的workQueue并唤醒内部线程
for (;;) { // possibly replace
WorkQueue[] ws; int m, sp;
//尝试终止线程池
if (tryTerminate(false, false) || w == null || w.array == null ||
(runState & STOP) != 0 || (ws = workQueues) == null ||
(m = ws.length - 1) < 0) // already terminating
break;
//唤醒被替换的线程,依赖于下一步
if ((sp = (int)(c = ctl)) != 0) { // wake up replacement
if (tryRelease(c, ws[sp & m], AC_UNIT))
break;
}
//创建工作线程替换
else if (ex != null && (c & ADD_WORKER) != 0L) {
tryAddWorker(c); // create replacement
break;
}
else // don't need replacement
break;
}
//5.处理异常
if (ex == null) // help clean on way out
ForkJoinTask.helpExpungeStaleExceptions();
else // rethrow
ForkJoinTask.rethrow(ex);
}

说明:deregisterWorker方法用于工作线程运行完毕之后终止线程或处理工作线程异常,主要就是清除已关闭的工作线程或回滚创建线程之前的操作,并把传入的异常抛给 ForkJoinTask 来处理

7、小结

以上我们对任务的执行流程进行了说明,后面我们将继续介绍任务的结果获取(join/invoke)。

5、获取任务结果——ForkJoinTask.join()/ForkJoinTask.invoke()
  • join()

    • //合并任务结果
      public final V join() {
          int s;
          if ((s = doJoin() & DONE_MASK) != NORMAL)
              reportException(s);
          return getRawResult();
      }
      
      //join, get, quietlyJoin的主实现方法
      private int doJoin() {
          int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
          return (s = status) < 0 ? s :
          ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
              (w = (wt = (ForkJoinWorkerThread)t).workQueue).
              tryUnpush(this) && (s = doExec()) < 0 ? s :
          wt.pool.awaitJoin(w, this, 0L) :
          externalAwaitDone();
      }
      
      final int doExec() {
          int s; boolean completed; 
          if ((s = status) >= 0) {
              try {
                  completed = exec(); 
              } catch (Throwable rex) {
                  return setExceptionalCompletion(rex); 
              }
              if (completed) 
                  s = setCompletion(NORMAL); 
          }
          return s; 
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
          
      - 它首先调用 doJoin 方法,通过 doJoin()方法得到当前任务的状态来判断返回什么结果,任务状态有 4 种: ==已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常(EXCEPTIONAL)==

      - 如果任务状态是已完成,则直接返回任务结果。
      - 如果任务状态是被取消,则直接抛出 CancellationException
      - 如果任务状态是抛出异常,则直接抛出对应的异常

      - 在 doJoin()方法流程如下:

      1. 首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成,则直接返回任务状态;
      2. 如果没有执行完,则从任务数组里取出任务并执行。
      3. 如果任务顺利执行完成,则设置任务状态为 NORMAL,如果出现异常,则记录异常,并将任务状态设置为 EXCEPTIONAL。

      - invoke()

      - ```java
      //执行任务,并等待任务完成并返回结果
      public final V invoke() {
      int s;
      if ((s = doInvoke() & DONE_MASK) != NORMAL)
      reportException(s);
      return getRawResult();
      }

      //invoke, quietlyInvoke的主实现方法
      private int doInvoke() {
      int s; Thread t; ForkJoinWorkerThread wt;
      return (s = doExec()) < 0 ? s :
      ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
      (wt = (ForkJoinWorkerThread)t).pool.
      awaitJoin(wt.workQueue, this, 0L) :
      externalAwaitDone();
      }

说明:join()方法一把是在任务fork()之后调用,用来获取(或者叫“合并”)任务的执行结果。

ForkJoinTask的join()和invoke()方法都可以用来获取任务的执行结果(另外还有get方法也是调用了doJoin来获取任务结果,但是会响应运行时异常),它们对外部提交任务的执行方式一致,都是通过externalAwaitDone方法等待执行结果。

不同的是invoke()方法会直接执行当前任务;而join()方法则是在当前任务在队列 top 位时(通过tryUnpush方法判断)才能执行,如果当前任务不在 top 位或者任务执行失败调用ForkJoinPool.awaitJoin方法帮助执行或阻塞当前 join 任务。(所以在官方文档中建议了我们对ForkJoinTask任务的调用顺序,一对 fork-join操作一般按照如下顺序调用:a.fork(); b.fork(); b.join(); a.join();。因为任务 b 是后面进入队列,也就是说它是在栈顶的(top 位),在它fork()之后直接调用join()就可以直接执行而不会调用ForkJoinPool.awaitJoin方法去等待。)

在这些方法中,join()相对比较全面,所以之后的讲解我们将从join()开始逐步向下分析,首先看一下join()的执行流程:

img

后面的源码分析中,我们首先讲解比较简单的外部 join 任务(externalAwaitDone),然后再讲解内部 join 任务(从ForkJoinPool.awaitJoin()开始)。

1、ForkJoinTask.externalAwaitDone()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private int externalAwaitDone() {
//执行任务
int s = ((this instanceof CountedCompleter) ? // try helping
ForkJoinPool.common.externalHelpComplete( // CountedCompleter任务
(CountedCompleter<?>)this, 0) :
ForkJoinPool.common.tryExternalUnpush(this) ? doExec() : 0); // ForkJoinTask任务
if (s >= 0 && (s = status) >= 0) {//执行失败,进入等待
boolean interrupted = false;
do {
if (U.compareAndSwapInt(this, STATUS, s, s | SIGNAL)) { //更新state
synchronized (this) {
if (status >= 0) {//SIGNAL 等待信号
try {
wait(0L);
} catch (InterruptedException ie) {
interrupted = true;
}
}
else
notifyAll();
}
}
} while ((s = status) >= 0);
if (interrupted)
Thread.currentThread().interrupt();
}
return s;
}

说明:如果当前join为外部调用,则调用此方法执行任务,如果任务执行失败就进入等待。方法本身是很简单的,需要注意的是对不同的任务类型分两种情况

  • 如果我们的任务为 CountedCompleter 类型的任务,则调用externalHelpComplete方法来执行任务。
  • 其他类型的 ForkJoinTask 任务调用tryExternalUnpush来执行

tryExternalUnpush的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//为外部提交者提供 tryUnpush 功能(给定任务在top位时弹出任务)
final boolean tryExternalUnpush(ForkJoinTask<?> task) {
WorkQueue[] ws;
WorkQueue w;
ForkJoinTask<?>[] a;
int m, s;
int r = ThreadLocalRandom.getProbe();
if ((ws = workQueues) != null && (m = ws.length - 1) >= 0 &&
(w = ws[m & r & SQMASK]) != null &&
(a = w.array) != null && (s = w.top) != w.base) {
long j = (((a.length - 1) & (s - 1)) << ASHIFT) + ABASE; //取top位任务
if (U.compareAndSwapInt(w, QLOCK, 0, 1)) { //加锁
if (w.top == s && w.array == a &&
U.getObject(a, j) == task &&
U.compareAndSwapObject(a, j, task, null)) { //符合条件,弹出
U.putOrderedInt(w, QTOP, s - 1); //更新top
U.putOrderedInt(w, QLOCK, 0); //解锁,返回true
return true;
}
U.compareAndSwapInt(w, QLOCK, 1, 0); //当前任务不在top位,解锁返回false
}
}
return false;
}

tryExternalUnpush的作用就是判断当前任务是否在top位,如果是则弹出任务,然后在externalAwaitDone中调用doExec()执行任务

2、ForkJoinPool.awaitJoin()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
final int awaitJoin(WorkQueue w, ForkJoinTask<?> task, long deadline) {
int s = 0;
if (task != null && w != null) {
ForkJoinTask<?> prevJoin = w.currentJoin; //获取给定Worker的join任务
U.putOrderedObject(w, QCURRENTJOIN, task); //把currentJoin替换为给定任务
//判断是否为CountedCompleter类型的任务
CountedCompleter<?> cc = (task instanceof CountedCompleter) ?
(CountedCompleter<?>) task : null;
for (; ; ) {
if ((s = task.status) < 0) //已经完成|取消|异常 跳出循环
break;

if (cc != null)//CountedCompleter任务由helpComplete来完成join
helpComplete(w, cc, 0);
else if (w.base == w.top || w.tryRemoveAndExec(task)) //尝试执行
helpStealer(w, task); //队列为空或执行失败,任务可能被偷,帮助偷取者执行该任务

if ((s = task.status) < 0) //已经完成|取消|异常,跳出循环
break;
//计算任务等待时间
long ms, ns;
if (deadline == 0L)
ms = 0L;
else if ((ns = deadline - System.nanoTime()) <= 0L)
break;
else if ((ms = TimeUnit.NANOSECONDS.toMillis(ns)) <= 0L)
ms = 1L;

if (tryCompensate(w)) {//执行补偿操作
task.internalWait(ms);//补偿执行成功,任务等待指定时间
U.getAndAddLong(this, CTL, AC_UNIT);//更新活跃线程数
}
}
U.putOrderedObject(w, QCURRENTJOIN, prevJoin);//循环结束,替换为原来的join任务
}
return s;
}

说明:如果当前 join 任务不在Worker等待队列的top位,或者任务执行失败,调用此方法来帮助执行或阻塞当前 join 的任务。

函数执行流程如下:

  • 由于每次调用awaitJoin都会优先执行当前join的任务,所以首先会更新currentJoin为当前join任务;
  • 进入自旋:
    • 首先检查任务是否已经完成(通过task.status < 0判断),如果给定任务执行完毕|取消|异常,则跳出循环返回执行状态s;
    • 如果是 CountedCompleter 任务类型,调用helpComplete方法来完成join操作(后面笔者会开新篇来专门讲解CountedCompleter,本篇暂时不做详细解析);
    • 非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务;
    • 如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务);
    • 再次判断任务是否执行完毕(task.status < 0),如果任务执行失败,计算一个等待时间准备进行补偿操作;
    • 调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。在执行补偿期间,如果发现资源争用|池处于unstable状态|当前Worker已终止,则调用ForkJoinTask.internalWait()方法等待指定的时间,任务唤醒之后继续自旋。

ForkJoinTask.internalWait()源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
final void internalWait(long timeout) {
int s;
if ((s = status) >= 0 && // force completer to issue notify
U.compareAndSwapInt(this, STATUS, s, s | SIGNAL)) {//更新任务状态为SIGNAL(等待唤醒)
synchronized (this) {
if (status >= 0)
try { wait(timeout); } catch (InterruptedException ie) { }
else
notifyAll();
}
}
}

在awaitJoin中,我们总共调用了三个比较复杂的方法:tryRemoveAndExechelpStealertryCompensate,下面我们依次讲解。

3、WorkQueue.tryRemoveAndExec(ForkJoinTask<?> task)

非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
final boolean tryRemoveAndExec(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a;
int m, s, b, n;
if ((a = array) != null && (m = a.length - 1) >= 0 &&
task != null) {
while ((n = (s = top) - (b = base)) > 0) {
//从top往下自旋查找
for (ForkJoinTask<?> t; ; ) { // traverse from s to b
long j = ((--s & m) << ASHIFT) + ABASE;//计算任务索引
if ((t = (ForkJoinTask<?>) U.getObject(a, j)) == null) //获取索引到的任务
return s + 1 == top; // shorter than expected
else if (t == task) { //给定任务为索引任务
boolean removed = false;
if (s + 1 == top) { // pop
if (U.compareAndSwapObject(a, j, task, null)) { //弹出任务
U.putOrderedInt(this, QTOP, s); //更新top
removed = true;
}
} else if (base == b) // replace with proxy
removed = U.compareAndSwapObject(
a, j, task, new EmptyTask()); //join任务已经被移除,替换为一个占位任务
if (removed)
task.doExec(); //执行
break;
} else if (t.status < 0 && s + 1 == top) { //给定任务不是top任务
if (U.compareAndSwapObject(a, j, t, null)) //弹出任务
U.putOrderedInt(this, QTOP, s);//更新top
break; // was cancelled
}
if (--n == 0) //遍历结束
return false;
}
if (task.status < 0) //任务执行完毕
return false;
}
}
return true;
}

说明:从top位开始自旋向下找到给定任务,如果找到把它从当前 Worker 的任务队列中移除并执行它。

注意返回的参数:如果任务队列为空或者任务未执行完毕返回true;任务执行完毕返回false。

4、ForkJoinPool.helpStealer(WorkQueue w, ForkJoinTask<?> task)

如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
private void helpStealer(WorkQueue w, ForkJoinTask<?> task) {
WorkQueue[] ws = workQueues;
int oldSum = 0, checkSum, m;
if (ws != null && (m = ws.length - 1) >= 0 && w != null &&
task != null) {
do { // restart point
checkSum = 0; // for stability check
ForkJoinTask<?> subtask;
WorkQueue j = w, v; // v is subtask stealer
descent:
for (subtask = task; subtask.status >= 0; ) {
//1. 找到给定WorkQueue的偷取者v
for (int h = j.hint | 1, k = 0, i; ; k += 2) {//跳两个索引,因为Worker在奇数索引位
if (k > m) // can't find stealer
break descent;
if ((v = ws[i = (h + k) & m]) != null) {
if (v.currentSteal == subtask) {//定位到偷取者
j.hint = i;//更新stealer索引
break;
}
checkSum += v.base;
}
}
//2. 帮助偷取者v执行任务
for (; ; ) { // help v or descend
ForkJoinTask<?>[] a; //偷取者内部的任务
int b;
checkSum += (b = v.base);
ForkJoinTask<?> next = v.currentJoin;//获取偷取者的join任务
if (subtask.status < 0 || j.currentJoin != subtask ||
v.currentSteal != subtask) // stale
break descent; // stale,跳出descent循环重来
if (b - v.top >= 0 || (a = v.array) == null) {
if ((subtask = next) == null) //偷取者的join任务为null,跳出descent循环
break descent;
j = v;
break; //偷取者内部任务为空,可能任务也被偷走了;跳出本次循环,查找偷取者的偷取者
}
int i = (((a.length - 1) & b) << ASHIFT) + ABASE;//获取base偏移地址
ForkJoinTask<?> t = ((ForkJoinTask<?>)
U.getObjectVolatile(a, i));//获取偷取者的base任务
if (v.base == b) {
if (t == null) // stale
break descent; // stale,跳出descent循环重来
if (U.compareAndSwapObject(a, i, t, null)) {//弹出任务
v.base = b + 1; //更新偷取者的base位
ForkJoinTask<?> ps = w.currentSteal;//获取调用者偷来的任务
int top = w.top;
//首先更新给定workQueue的currentSteal为偷取者的base任务,然后执行该任务
//然后通过检查top来判断给定workQueue是否有自己的任务,如果有,
// 则依次弹出任务(LIFO)->更新currentSteal->执行该任务(注意这里是自己偷自己的任务执行)
do {
U.putOrderedObject(w, QCURRENTSTEAL, t);
t.doExec(); // clear local tasks too
} while (task.status >= 0 &&
w.top != top && //内部有自己的任务,依次弹出执行
(t = w.pop()) != null);
U.putOrderedObject(w, QCURRENTSTEAL, ps);//还原给定workQueue的currentSteal
if (w.base != w.top)//给定workQueue有自己的任务了,帮助结束,返回
return; // can't further help
}
}
}
}
} while (task.status >= 0 && oldSum != (oldSum = checkSum));
}
}

说明:如果队列为空或任务执行失败,说明任务可能被偷,调用此方法来帮助偷取者执行任务。

基本思想是:偷取者帮助我执行任务,我去帮助偷取者执行它的任务。 函数执行流程如下:

  1. 循环定位偷取者,由于Worker是在奇数索引位,所以每次会跳两个索引位。
  2. 定位到偷取者之后,更新调用者 WorkQueue 的hint为偷取者的索引,方便下次定位;
  3. 定位到偷取者后,开始帮助偷取者执行任务。从偷取者的base索引开始,每次偷取一个任务执行。
  4. 在帮助偷取者执行任务后,如果调用者发现本身已经有任务(w.top != top),则依次弹出自己的任务(LIFO顺序)并执行(也就是说自己偷自己的任务执行)。
5、ForkJoinPool.tryCompensate(WorkQueue w)

调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//执行补偿操作: 尝试缩减活动线程量,可能释放或创建一个补偿线程来准备阻塞
private boolean tryCompensate(WorkQueue w) {
boolean canBlock;
WorkQueue[] ws;
long c;
int m, pc, sp;
if (w == null || w.qlock < 0 || // caller terminating
(ws = workQueues) == null || (m = ws.length - 1) <= 0 ||
(pc = config & SMASK) == 0) // parallelism disabled
canBlock = false; //调用者已终止
else if ((sp = (int) (c = ctl)) != 0) // release idle worker
canBlock = tryRelease(c, ws[sp & m], 0L);//唤醒等待的工作线程
else {//没有空闲线程
int ac = (int) (c >> AC_SHIFT) + pc; //活跃线程数
int tc = (short) (c >> TC_SHIFT) + pc;//总线程数
int nbusy = 0; // validate saturation
for (int i = 0; i <= m; ++i) { // two passes of odd indices
WorkQueue v;
if ((v = ws[((i << 1) | 1) & m]) != null) {//取奇数索引位
if ((v.scanState & SCANNING) != 0)//没有正在运行任务,跳出
break;
++nbusy;//正在运行任务,添加标记
}
}
if (nbusy != (tc << 1) || ctl != c)
canBlock = false; // unstable or stale
else if (tc >= pc && ac > 1 && w.isEmpty()) {//总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空,不需要补偿
long nc = ((AC_MASK & (c - AC_UNIT)) |
(~AC_MASK & c)); // uncompensated
canBlock = U.compareAndSwapLong(this, CTL, c, nc);//更新活跃线程数
} else if (tc >= MAX_CAP ||
(this == common && tc >= pc + commonMaxSpares))//超出最大线程数
throw new RejectedExecutionException(
"Thread limit exceeded replacing blocked worker");
else { // similar to tryAddWorker
boolean add = false;
int rs; // CAS within lock
long nc = ((AC_MASK & c) |
(TC_MASK & (c + TC_UNIT)));//计算总线程数
if (((rs = lockRunState()) & STOP) == 0)
add = U.compareAndSwapLong(this, CTL, c, nc);//更新总线程数
unlockRunState(rs, rs & ~RSLOCK);
//运行到这里说明活跃工作线程数不足,需要创建一个新的工作线程来补偿
canBlock = add && createWorker(); // throws on exception
}
}
return canBlock;
}

说明:具体的执行看源码及注释,这里我们简单总结一下需要和不需要补偿的几种情况:

  • 需要补偿
    • 调用者队列不为空,并且有空闲工作线程,这种情况会唤醒空闲线程(调用tryRelease方法)
    • 池尚未停止,活跃线程数不足,这时会新建一个工作线程(调用createWorker方法)
  • 不需要补偿
    • 调用者已终止或池处于不稳定状态
    • 总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空

6、Fork/Join的陷阱与注意事项

使用Fork/Join框架时,需要注意一些陷阱, 在下面 斐波那契数列例子中你将看到示例。

1、避免不必要的fork()

划分成两个子任务后,不要同时调用两个子任务的fork()方法。

表面上看上去两个子任务都fork(),然后join()两次似乎更自然。但事实证明,直接调用compute()效率更高。因为直接调用子任务的compute()方法实际上就是在当前的工作线程进行了计算(线程重用),这比“将子任务提交到工作队列,线程又从工作队列中拿任务”快得多

当一个大任务被划分成两个以上的子任务时,尽可能使用前面说到的三个衍生的invokeAll方法,因为使用它们能避免不必要的fork()。

2、注意fork()、compute()、join()的顺序

为了两个任务并行,三个方法的调用顺序需要万分注意。

1
2
3
4
right.fork(); // 计算右边的任务
long leftAns = left.compute(); // 计算左边的任务(同时右边任务也在计算)
long rightAns = right.join(); // 等待右边的结果
return leftAns + rightAns;

如果我们写成:

1
2
3
4
left.fork(); // 计算完左边的任务
long leftAns = left.join(); // 等待左边的计算结果
long rightAns = right.compute(); // 再计算右边的任务
return leftAns + rightAns;

或者:

1
2
3
4
long rightAns = right.compute(); // 计算完右边的任务
left.fork(); // 再计算左边的任务
long leftAns = left.join(); // 等待左边的计算结果
return leftAns + rightAns;
3、选择合适的子任务粒度

选择划分子任务的粒度(顺序执行的阈值)很重要,因为使用Fork/Join框架并不一定比顺序执行任务的效率高:如果任务太大,则无法提高并行的吞吐量;如果任务太小,子任务的调度开销可能会大于并行计算的性能提升,我们还要考虑创建子任务、fork()子任务、线程调度以及合并子任务处理结果的耗时以及相应的内存消耗。

官方文档给出的粗略经验是:任务应该执行100~10000个基本的计算步骤。决定子任务的粒度的最好办法是实践,通过实际测试结果来确定这个阈值才是“上上策”。

和其他Java代码一样,Fork/Join框架测试时需要“预热”或者说执行几遍才会被JIT(Just-in-time)编译器优化,所以测试性能之前跑几遍程序很重要。

4、避免重量级任务划分与结果合并

Fork/Join的很多使用场景都用到数组或者List等数据结构,子任务在某个分区中运行,最典型的例子如并行排序和并行查找。拆分子任务以及合并处理结果的时候,应该尽量避免System.arraycopy这样耗时耗空间的操作,从而最小化任务的处理开销。

7、再深入理解

1、有哪些JDK源码中使用了Fork/Join思想

我们常用的数组工具类 Arrays 在JDK 8之后新增的==并行排序方法(parallelSort)==就运用了 ForkJoinPool 的特性,还有 ConcurrentHashMap 在JDK 8之后添加的==函数式方法(如forEach等)==也有运用。

2、使用Executors工具类创建ForkJoinPool

Java8在Executors工具类中新增了两个工厂方法:

1
2
3
4
5
// parallelism定义并行级别
public static ExecutorService newWorkStealingPool(int parallelism);
// 默认并行级别为JVM可用的处理器个数
// Runtime.getRuntime().availableProcessors()
public static ExecutorService newWorkStealingPool();
3、关于Fork/Join异常处理

Java的受检异常机制一直饱受诟病,所以在ForkJoinTask的invoke()、join()方法及其衍生方法中都没有像get()方法那样抛出个ExecutionException的受检异常。

所以你可以在ForkJoinTask中看到内部把受检异常转换成了运行时异常

1
2
3
4
5
6
7
8
9
static void rethrow(Throwable ex) {
if (ex != null)
ForkJoinTask.<RuntimeException>uncheckedThrow(ex);
}

@SuppressWarnings("unchecked")
static <T extends Throwable> void uncheckedThrow(Throwable t) throws T {
throw (T)t; // rely on vacuous cast
}

==关于Java你不知道的10件事==中已经指出,JVM实际并不关心这个异常是受检异常还是运行时异常,受检异常这东西完全是给Java编译器用的:用于警告程序员这里有个异常没有处理。

但不可否认的是invoke、join()仍可能会抛出运行时异常,所以ForkJoinTask还提供了两个不提取结果和异常的方法quietlyInvoke()、quietlyJoin(),这两个方法允许你在所有任务完成后对结果和异常进行处理。

使用quitelyInvoke()quietlyJoin()时可以配合isCompletedAbnormally()isCompletedNormally()方法使用。

ForkJoinTask 在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常,所以 ForkJoinTask 提供了 isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被取消了,并且可以通过 ForkJoinTask 的getException 方法获取异常。

getException 方法返回 Throwable 对象,如果任务被取消了则返回 CancellationException。如果任务没有完成或者没有抛出异常则返回 null。

8、一些Fork/Join例子

1、采用Fork/Join来异步计算1+2+3+……+10000的结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Test {
static final class SumTask extends RecursiveTask<Integer> {
private static final long serialVersionUID = 1L;

final int start; //开始计算的数
final int end; //最后计算的数

SumTask(int start, int end) {
this.start = start;
this.end = end;
}

@Override
protected Integer compute() {
//如果计算量小于1000,那么分配一个线程执行if中的代码块,并返回执行结果
if(end - start < 1000) {
System.out.println(Thread.currentThread().getName() + " 开始执行: " + start + "-" + end);
int sum = 0;
for(int i = start; i <= end; i++)
sum += i;
return sum;
}
//如果计算量大于1000,那么拆分为两个任务
SumTask task1 = new SumTask(start, (start + end) / 2);
SumTask task2 = new SumTask((start + end) / 2 + 1, end);
//执行任务
task1.fork();
task2.fork();
//获取任务执行的结果
return task1.join() + task2.join();
}
}

public static void main(String[] args) throws InterruptedException, ExecutionException {
ForkJoinPool pool = new ForkJoinPool();
ForkJoinTask<Integer> task = new SumTask(1, 10000);
pool.submit(task);
System.out.println(task.get());
}
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ForkJoinPool-1-worker-1 开始执行: 1-625
ForkJoinPool-1-worker-7 开始执行: 6251-6875
ForkJoinPool-1-worker-6 开始执行: 5626-6250
ForkJoinPool-1-worker-10 开始执行: 3751-4375
ForkJoinPool-1-worker-13 开始执行: 2501-3125
ForkJoinPool-1-worker-8 开始执行: 626-1250
ForkJoinPool-1-worker-11 开始执行: 5001-5625
ForkJoinPool-1-worker-3 开始执行: 7501-8125
ForkJoinPool-1-worker-14 开始执行: 1251-1875
ForkJoinPool-1-worker-4 开始执行: 9376-10000
ForkJoinPool-1-worker-8 开始执行: 8126-8750
ForkJoinPool-1-worker-0 开始执行: 1876-2500
ForkJoinPool-1-worker-12 开始执行: 4376-5000
ForkJoinPool-1-worker-5 开始执行: 8751-9375
ForkJoinPool-1-worker-7 开始执行: 6876-7500
ForkJoinPool-1-worker-1 开始执行: 3126-3750
50005000
2、实现斐波那契数列

斐波那契数列: 1、1、2、3、5、8、13、21、34、…… 公式 : F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool(4); // 最大并发数4
Fibonacci fibonacci = new Fibonacci(20);
long startTime = System.currentTimeMillis();
Integer result = forkJoinPool.invoke(fibonacci);
long endTime = System.currentTimeMillis();
System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
}
//以下为官方API文档示例
static class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) {
this.n = n;
}
@Override
protected Integer compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}

当然你也可以两个任务都fork,要注意的是两个任务都fork的情况,必须按照f1.fork(),f2.fork(), f2.join(),f1.join()这样的顺序,不然有性能问题,详见上面注意事项中的说明。

官方API文档是这样写到的,所以平日用invokeAll就好了。invokeAll会把传入的任务的第一个交给当前线程来执行,其他的任务都fork加入工作队列,这样等于利用当前线程也执行任务了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
// ...
Fibonacci f1 = new Fibonacci(n - 1);
Fibonacci f2 = new Fibonacci(n - 2);
invokeAll(f1,f2);
return f2.join() + f1.join();
}

public static void invokeAll(ForkJoinTask<?>... tasks) {
Throwable ex = null;
int last = tasks.length - 1;
for (int i = last; i >= 0; --i) {
ForkJoinTask<?> t = tasks[i];
if (t == null) {
if (ex == null)
ex = new NullPointerException();
}
else if (i != 0) //除了第一个都fork
t.fork();
else if (t.doInvoke() < NORMAL && ex == null) //留一个自己执行
ex = t.getException();
}
for (int i = 1; i <= last; ++i) {
ForkJoinTask<?> t = tasks[i];
if (t != null) {
if (ex != null)
t.cancel(false);
else if (t.doJoin() < NORMAL)
ex = t.getException();
}
}
if (ex != null)
rethrow(ex);
}

21、CompletableFuture异步回调

1、CompletableFuture 简介

CompletableFuture 在 Java 里面被用于异步编程,异步通常意味着非阻塞, 可以使得我们的任务单独运行在与主线程分离的其他线程中,并且通过回调可以在主线程中得到异步任务的执行状态,是否完成,和是否异常等信息。

CompletableFuture 实现了 Future, CompletionStage 接口:

  • 实现了 Future 接口就可以兼容现在有线程池框架
  • 而 CompletionStage 接口才是异步编程的接口抽象,里面定义多种异步方法

通过这两者集合,从而打造出了强大的CompletableFuture 类。

2、Future 与 CompletableFuture

Futrue 在 Java 里面,通常用来表示一个异步任务的引用,比如我们将任务提交到线程池里面,然后我们会得到一个 Futrue,在 Future 里面有 isDone 方法来 判断任务是否处理结束,还有 get 方法可以一直阻塞直到任务结束然后获取结果,但整体来说这种方式,还是同步的,因为需要客户端不断阻塞等待或者不断轮询才能知道任务是否完成。

3、Future 的主要缺点

  1. 不支持手动完成
    • 我提交了一个任务,但是执行太慢了,我通过其他路径已经获取到了任务结果, 现在没法把这个任务结果通知到正在执行的线程,所以必须主动取消或者一直等待它执行完成
  2. 不支持进一步的非阻塞调用
    • 通过 Future 的 get 方法会一直阻塞到任务完成,但是想在获取任务之后执行额外的任务,因为 Future 不支持回调函数,所以无法实现这个功能
  3. 不支持链式调用
    • 对于 Future 的执行结果,我们想继续传到下一个 Future 处理使用,从而形成一个链式的 pipline 调用,这在 Future 中是没法实现的。
  4. 不支持多个 Future 合并
    • 比如我们有 10 个 Future 并行执行,我们想在所有的 Future 运行完毕之后, 执行某些函数,是没法通过 Future 实现的。
  5. 不支持异常处理
    • Future 的 API 没有任何的异常处理的 api,所以在异步运行时,如果出了问题是不好定位的。

4、CompletableFuture的使用

1、CompletableFuture 入门

场景:主线程里面创建一个 CompletableFuture,然后主线程调用 get 方法会阻塞,最后我们在一个子线程中使其终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CompletableFutureTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
CompletableFuture<String> future = new CompletableFuture<>();
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + "子线程开始干活");
//子线程睡 5 秒
Thread.sleep(5000);
//在子线程中完成主线程
future.complete("success");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A").start();
//主线程调用 get 方法阻塞
System.out.println("主线程调用 get 方法获取结果为: " + future.get());
System.out.println("主线程完成,阻塞结束!!!!!!");
}
}

结果:

1
2
3
A子线程开始干活
主线程调用 get 方法获取结果为: success
主线程完成,阻塞结束!!!!!!
2、没有返回值的同步任务
1
2
3
4
5
6
7
8
9
10
//没有返回值的同步任务
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
//同步调用
CompletableFuture<Void> completableFuture1 = CompletableFuture.runAsync(()->{
System.out.println(Thread.currentThread().getName()+" : CompletableFuture1");
});
completableFuture1.get();
}
}

同步任务调用runAsync()方法,使用get()方法获取

3、有返回值的异步任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//异步调用和同步调用
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
//异步调用
CompletableFuture<Integer> completableFuture2 = CompletableFuture.supplyAsync(()->{
System.out.println(Thread.currentThread().getName()+" : CompletableFuture2");
//模拟异常
int i = 10/0;
return 1024;
});
completableFuture2.whenComplete((t,u)->{
System.out.println("------t="+t);
System.out.println("------u="+u);
}).get();

}
}

异步调用使用supplyAsync()方法,可以通过whenComplete()获取。

其中supplyAsync()方法的参数是一个Supplier类,而Supplier类是一个函数式接口,可以使用lambda表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) {
return asyncSupplyStage(asyncPool, supplier);
}

@FunctionalInterface
public interface Supplier<T> {

/**
* Gets a result.
*
* @return a result
*/
T get();
}

其中whenComplete()的两个参数:

  • t:用来接收异步调用中正常返回的结果,此时的u返回null
  • u:用来接收异步调用过程中出现的异常,此时的t返回null

关于一些函数式接口的接口类:

  • supplier:提供者,特点:无中生有 :() -> 结果
  • function:函数,特点:一个参数一个结果 :(参数) -> 结果
    • BiFunction:两个参数一个结果 :(参数1,参数2) -> 结果
  • consumer:消费者,特点:一个参数没结果:(参数) -> void
    • BiConsumer:两个参数,没有结果:(参数1,参数2) -> void
4、线程依赖

当一个线程依赖另一个线程时,可以使用 thenApply 方法来把这两个线程串行化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
try {
System.out.println("加 10 任务开始");
num += 10;
} catch (Exception e) {
e.printStackTrace();
}
return num;
}).thenApply(integer -> {
return num * num;
});
Integer integer = future.get();
System.out.println("主线程结束, 子线程的结果为:" + integer);
}
}
1
2
3
主线程开始
加 10 任务开始
主线程结束, 子线程的结果为:400
5、消费处理结果

thenAccept 消费处理结果,接收任务的处理结果,并消费处理,无返回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture.supplyAsync(() -> {
try {
System.out.println("加 10 任务开始");
num += 10;
} catch (Exception e) {
e.printStackTrace();
}
return num;
}).thenApply(integer -> {
return num * num;
}).thenAccept(new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println("子线程全部处理完成,最后调用了 accept,结果为:" + integer);
}
});
}
}
1
2
3
主线程开始
加 10 任务开始
子线程全部处理完成,最后调用了 accept,结果为:400
6、异常处理

exceptionally 异常处理,出现异常时触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 1 / 0;
System.out.println("加 10 任务开始");
num += 10;
return num;
}).exceptionally(ex -> {
System.out.println(ex.getMessage());
return -1;
});
System.out.println(future.get());
}
}

结果:

1
2
3
主线程开始
java.lang.ArithmeticException: / by zero
-1

handle 类似于 thenAccept/thenRun 方法,是最后一步的处理调用,但是同时可以处理异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 1 / 0;
System.out.println("加 10 任务开始");
num += 10;
return num;
}).handle((i, ex) -> {
System.out.println("进入 handle 方法");
if (ex != null) {
System.out.println("发生了异常,内容为:" + ex.getMessage());
return -1;
} else {
System.out.println("正常完成,内容为: " + i);
return i;
}
});
System.out.println(future.get());
}
}

结果:

1
2
3
4
主线程开始
进入 handle 方法
发生了异常,内容为:java.lang.ArithmeticException: / by zero
-1
7、结果合并

thenCompose 合并两个有依赖关系的 CompletableFutures 的执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
//第一步加 10
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
//合并
CompletableFuture<Integer> future1 = future.thenCompose(i ->
//再来一个CompletableFuture
CompletableFuture.supplyAsync(() -> {
return i + 1;
}));
System.out.println(future.get());
System.out.println(future1.get());
}
}

结果:

1
2
3
4
主线程开始
加 10 任务开始
20
21

thenCombine 合并两个没有依赖关系的 CompletableFutures 任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
System.out.println("乘以 10 任务开始");
num = num * 10;
return num;
});
//合并两个结果
CompletableFuture<Object> future = job1.thenCombine(job2, (BiFunction<Integer, Integer, List<Integer>>) (a, b) -> {
List<Integer> list = new ArrayList<>();
list.add(a);
list.add(b);
return list;
});
System.out.println("合并结果为:" + future.get());
}
}

结果:

1
2
3
4
主线程开始
加 10 任务开始
乘以 10 任务开始
合并结果为:[20, 200]
8、合并多个任务的结果allOf 与anyOf

allOf:一系列独立的future 任务,等其所有的任务执行完后做一些事情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class CompletableFutureTest {
private static Integer num = 10;
public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
List<CompletableFuture> list = new ArrayList<>();
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
list.add(job1);
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
System.out.println("乘以 10 任务开始");
num *= 10;
return num;
});
list.add(job2);
CompletableFuture<Integer> job3 = CompletableFuture.supplyAsync(() -> {
System.out.println("减以 10 任务开始");
num -= 10;
return num;
});
list.add(job3);
CompletableFuture<Integer> job4 = CompletableFuture.supplyAsync(() -> {
System.out.println("除以 10 任务开始");
num /= 10;
return num;
});
list.add(job4);
//多任务合并
List<Integer> collect = list.stream().map(CompletableFuture<Integer>::join).collect(Collectors.toList());
System.out.println(collect);
}
}
1
2
3
4
5
6
主线程开始
加 10 任务开始
乘以 10 任务开始
减以 10 任务开始
除以 10 任务开始
[20, 200, 190, 19]

anyOf:只要在多个future 里面有一个返回,整个任务就可以结束,而不需要等到每一个 future 结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class CompletableFutureTest {
private static Integer num = 10;
public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer>[] futures = new CompletableFuture[4];
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000);
System.out.println("加 10 任务开始");
num += 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 0;
}
});
futures[0] = job1;
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(2000);
System.out.println("乘以 10 任务开始");
num *= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 1;
}
});
futures[1] = job2;
CompletableFuture<Integer> job3 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(3000);
System.out.println("减以 10 任务开始");
num -= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 2;
}
});
futures[2] = job3;
CompletableFuture<Integer> job4 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(4000);
System.out.println("除以 10 任务开始");
num /= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 3;
}
});
futures[3] = job4;
CompletableFuture<Object> future = CompletableFuture.anyOf(futures);
System.out.println(future.get());
}
}

结果:

1
2
3
主线程开始
乘以 10 任务开始
100

22、Java 并发 - ThreadLocal详解

ThreadLocal是通过线程隔离的方式防止任务在共享资源上产生冲突,线程本地存储是一种自动化机制,可以为使用相同变量的每个不同线程都创建不同的存储。

1、BAT大厂的面试问题

  • 什么是ThreadLocal?用来解决什么问题的?
  • 说说你对ThreadLocal的理解
  • ThreadLocal是如何实现线程隔离的?
  • 为什么ThreadLocal会造成内存泄露?如何解决?
  • 还有哪些使用ThreadLocal的应用场景?

2、ThreadLocal简介

我们在==Java 并发 - 并发理论基础==总结过线程安全(是指广义上的共享资源访问安全性,因为线程隔离是通过副本保证本线程访问资源安全性,它不保证线程之间还存在共享关系的狭义上的安全性)的解决思路:

  • 互斥同步synchronizedReentrantLock
  • 非阻塞同步CASAtomicXXXX
  • 无同步方案栈封闭本地存储(Thread Local)可重入代码

这个章节将详细的讲讲 本地存储(Thread Local)。官网的解释是这样的:

his class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID) 该类提供了线程局部 (thread-local) 变量。这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。

总结而言:ThreadLocal是一个将在多线程中为每一个线程创建单独的变量副本的类;当使用ThreadLocal来维护变量时,ThreadLocal会为每个线程创建单独的变量副本,避免因多线程操作共享变量而导致的数据不一致的情况

3、ThreadLocal理解

提到ThreadLocal被提到应用最多的是session管理和数据库链接管理,这里以数据访问为例来理解ThreadLocal:

如下数据库管理类在单线程使用是没有任何问题的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ConnectionManager {
private static Connection connect = null;

public static Connection openConnection() {
if (connect == null) {
connect = DriverManager.getConnection();
}
return connect;
}

public static void closeConnection() {
if (connect != null)
connect.close();
}
}

很显然,在多线程中使用会存在线程安全问题:

  1. 第一,这里面的2个方法都没有进行同步,很可能在openConnection方法中会多次创建connect;
  2. 第二,由于connect是共享变量,那么必然在调用connect的地方需要使用到同步来保障线程安全,因为很可能一个线程在使用connect进行数据库操作,而另外一个线程调用closeConnection关闭链接。

为了解决上述线程安全的问题,第一考虑:互斥同步

你可能会说,将这段代码的两个方法进行同步处理,并且在调用connect的地方需要进行同步处理,比如用Synchronized或者ReentrantLock互斥锁

这里再抛出一个问题:这地方到底需不需要将connect变量进行共享?

事实上,是不需要的。假如每个线程中都有一个connect变量,各个线程之间对connect变量的访问实际上是没有依赖关系的,即一个线程不需要关心其他线程是否对这个connect进行了修改的。即改后的代码可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class ConnectionManager {
private Connection connect = null;

public Connection openConnection() {
if (connect == null) {
connect = DriverManager.getConnection();
}
return connect;
}

public void closeConnection() {
if (connect != null)
connect.close();
}
}

class Dao {
public void insert() {
ConnectionManager connectionManager = new ConnectionManager();
Connection connection = connectionManager.openConnection();

// 使用connection进行操作

connectionManager.closeConnection();
}
}

这样处理确实也没有任何问题,由于每次都是在方法内部创建的连接,那么线程之间自然不存在线程安全问题

但是这样会有一个致命的影响:导致服务器压力非常大,并且严重影响程序执行性能。由于在方法中需要频繁地开启和关闭数据库连接,这样不仅严重影响程序执行效率,还可能导致服务器压力巨大

这时候ThreadLocal登场了

那么这种情况下使用ThreadLocal是再适合不过的了,因为ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。下面就是网上出现最多的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ConnectionManager {

private static final ThreadLocal<Connection> dbConnectionLocal = new ThreadLocal<Connection>() {
@Override
protected Connection initialValue() {
try {
return DriverManager.getConnection("", "", "");
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
};

public Connection getConnection() {
return dbConnectionLocal.get();
}
}

再注意下ThreadLocal的修饰符

ThreaLocal的JDK文档中说明:ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread。如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。

但是要注意,虽然ThreadLocal能够解决上面说的问题,但是由于在每个线程中都创建了副本,所以要考虑它对资源的消耗,比如内存的占用会比不使用ThreadLocal要大

4、ThreadLocal原理

1、如何实现线程隔离

主要是用到了Thread对象中的一个ThreadLocalMap类型的变量threadLocals,负责存储当前线程的关于Connection的对象,dbConnectionLocal(以上述例子中为例) 这个变量为Key,以新建的Connection对象为Value;这样的话,线程第一次读取的时候如果不存在就会调用ThreadLocal的initialValue方法创建一个Connection对象并且返回。

具体关于为线程分配变量副本的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
  • 首先获取当前线程对象t,然后从线程t中获取到ThreadLocalMap的成员属性threadLocals
  • 如果当前线程的threadLocals已经初始化(即不为null) 并且存在以当前ThreadLocal对象为Key的值,则直接返回当前线程要获取的对象(本例中为Connection);
  • 如果当前线程的threadLocals已经初始化(即不为null)但是不存在以当前ThreadLocal对象为Key的的对象,那么重新创建一个Connection对象,并且添加到当前线程的threadLocals Map中,并返回;
  • 如果当前线程的threadLocals属性还没有被初始化,则重新创建一个ThreadLocalMap对象,并且创建一个Connection对象并添加到ThreadLocalMap对象中并返回。

如果存在则直接返回很好理解,那么对于如何初始化的代码又是怎样的呢?

1
2
3
4
5
6
7
8
9
10
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
  • 首先调用我们上面写的重载过后的initialValue方法,产生一个Connection对象
  • 继续查看当前线程的threadLocals是不是空的,如果ThreadLocalMap已被初始化,那么直接将产生的对象添加到ThreadLocalMap中,如果没有初始化,则创建并添加对象到其中;

同时,ThreadLocal还提供了直接操作Thread对象中的threadLocals的方法:

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

这样我们也可以不实现initialValue,将初始化工作放到DBConnectionFactory的getConnection方法中:

1
2
3
4
5
6
7
8
9
10
11
12
public Connection getConnection() {
Connection connection = dbConnectionLocal.get();
if (connection == null) {
try {
connection = DriverManager.getConnection("", "", "");
dbConnectionLocal.set(connection);
} catch (SQLException e) {
e.printStackTrace();
}
}
return connection;
}

那么我们看过代码之后就很清晰的知道了为什么ThreadLocal能够实现变量的多线程隔离了;其实就是用了Map的数据结构给当前线程缓存了,要使用的时候就从本线程的threadLocals对象中获取就可以了,key就是当前线程

当然了在当前线程下获取当前线程里面的Map里面的对象并操作肯定没有线程并发问题了,当然能做到变量的线程间隔离了;

现在我们知道了ThreadLocal到底是什么了,又知道了如何使用ThreadLocal以及其基本实现原理了,是不是就可以结束了呢?其实还有一个问题就是ThreadLocalMap是个什么对象,为什么要用这个对象呢?

2、ThreadLocalMap对象是什么

本质上来讲,它就是一个Map,但是这个ThreadLocalMap与我们平时见到的Map有点不一样:

  • 它没有实现Map接口
  • 它没有public的方法,最多有一个default的构造方法,因为这个ThreadLocalMap的方法仅仅在ThreadLocal类中调用,属于静态内部类
  • ThreadLocalMap的Entry实现继承了WeakReference<ThreadLocal<?>>
  • 该方法仅仅用了一个Entry数组来存储Key、Value;Entry并不是链表形式,而是每个bucket里面仅仅放一个Entry

要了解ThreadLocalMap的实现,我们先从入口开始,就是往该Map中添加一个值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void set(ThreadLocal<?> key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.

Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

先进行简单的分析,对该代码表层意思进行解读:

  • 看下当前threadLocal的在数组中的索引位置 比如:i = 2,看 i = 2 位置上面的元素(Entry)的Key是否等于threadLocal 这个 Key,如果等于就很好说了,直接将该位置上面的Entry的Value替换成最新的就可以了;
  • 如果当前位置上面的 Entry 的 Key为空,说明ThreadLocal对象已经被回收了,那么就调用replaceStaleEntry
  • 如果清理完无用条目(ThreadLocal被回收的条目)、并且数组中的数据大小 > 阈值的时候对当前的Table进行重新哈希,所以,该HashMap是处理冲突检测的机制是向后移位,清除过期条目 最终找到合适的位置

了解完Set方法,后面就是Get方法了:

1
2
3
4
5
6
7
8
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}

先找到ThreadLocal的索引位置,如果索引位置处的entry不为空并且键与threadLocal是同一个对象,则直接返回;否则去后面的索引位置继续查找。

5、ThreadLocal造成内存泄漏的问题

网上有这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadLocalDemo {
static class LocalVariable {
private Long[] a = new Long[1024 * 1024];
}

// (1)
final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES,
new LinkedBlockingQueue<>());
// (2)
final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();

public static void main(String[] args) throws InterruptedException {
// (3)
Thread.sleep(5000 * 4);
for (int i = 0; i < 50; ++i) {
poolExecutor.execute(new Runnable() {
public void run() {
// (4)
localVariable.set(new LocalVariable());
// (5)
System.out.println("use local varaible" + localVariable.get());
localVariable.remove();
}
});
}
// (6)
System.out.println("pool execute over");
}
}

如果用线程池来操作ThreadLocal 对象确实会造成内存泄露,因为对于线程池里面不会销毁的线程,里面总会存在着<ThreadLocal, LocalVariable>的强引用,因为final static 修饰的 ThreadLocal 并不会释放,而ThreadLocalMap 对于 Key 虽然是弱引用,但是强引用不会释放,弱引用当然也会一直有值,同时创建的LocalVariable对象也不会释放,就造成了内存泄露

如果LocalVariable对象不是一个大对象的话,其实泄露的并不严重,泄露的内存 = 核心线程数 * LocalVariable对象的大小;

所以,为了避免出现内存泄露的情况,ThreadLocal提供了一个清除线程中对象的方法,即 remove,其实内部实现就是调用 ThreadLocalMap 的remove方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

找到Key对应的Entry,并且清除Entry的Key(ThreadLocal)置空,随后清除过期的Entry即可避免内存泄露

6、再看ThreadLocal应用场景

1、每个线程维护了一个“序列号”

再回想上文说的,如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。

每个线程维护了一个“序列号”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SerialNum {
// The next serial number to be assigned
private static int nextSerialNum = 0;

private static ThreadLocal serialNum = new ThreadLocal() {
protected synchronized Object initialValue() {
return new Integer(nextSerialNum++);
}
};

public static int get() {
return ((Integer) (serialNum.get())).intValue();
}
}

2、Session的管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static final ThreadLocal threadSession = new ThreadLocal();  

public static Session getSession() throws InfrastructureException {
Session s = (Session) threadSession.get();
try {
if (s == null) {
s = getSessionFactory().openSession();
threadSession.set(s);
}
} catch (HibernateException ex) {
throw new InfrastructureException(ex);
}
return s;
}
3、在线程内部创建ThreadLocal

还有一种用法是在线程类内部创建ThreadLocal,基本步骤如下:

  • 在多线程的类(如ThreadDemo类)中,创建一个ThreadLocal对象threadXxx,用来保存线程间需要隔离处理的对象xxx。
  • 在ThreadDemo类中,创建一个获取要隔离访问的数据的方法getXxx(),在方法中判断,若ThreadLocal对象为null时候,应该new()一个隔离访问类型的对象,并强制转换为要应用的类型。
  • 在ThreadDemo类的run()方法中,通过调用getXxx()方法获取要操作的数据,这样可以保证每个线程对应一个数据对象,在任何时刻都操作的是这个对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class ThreadLocalTest implements Runnable{

ThreadLocal<Student> StudentThreadLocal = new ThreadLocal<Student>();

@Override
public void run() {
String currentThreadName = Thread.currentThread().getName();
System.out.println(currentThreadName + " is running...");
Random random = new Random();
int age = random.nextInt(100);
System.out.println(currentThreadName + " is set age: " + age);
Student Student = getStudentt(); //通过这个方法,为每个线程都独立的new一个Studentt对象,每个线程的的Studentt对象都可以设置不同的值
Student.setAge(age);
System.out.println(currentThreadName + " is first get age: " + Student.getAge());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println( currentThreadName + " is second get age: " + Student.getAge());

}

private Student getStudentt() {
Student Student = StudentThreadLocal.get();
if (null == Student) {
Student = new Student();
StudentThreadLocal.set(Student);
}
return Student;
}

public static void main(String[] args) {
ThreadLocalTest t = new ThreadLocalTest();
Thread t1 = new Thread(t,"Thread A");
Thread t2 = new Thread(t,"Thread B");
t1.start();
t2.start();
}

}

class Student{
int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
4、java开发手册中推荐的ThreadLocal

看看阿里巴巴 java 开发手册中推荐的 ThreadLocal 的用法:

1
2
3
4
5
6
7
8
9
10
11
import java.text.DateFormat;
import java.text.SimpleDateFormat;

public class DateUtils {
public static final ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>(){
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd");
}
};
}

然后我们再要用到 DateFormat 对象的地方,这样调用:

1
DateUtils.df.get().format(new Date());

23、补充:阿姆达尔定律

阿姆达尔定律可以用来计算处理器平行运算之后效率提升的能力。阿姆达尔定律因Gene Amdal 在1967年提出这个定律而得名。绝大多数使用并行或并发系统的开发者有一种并发或并行可能会带来提速的感觉,甚至不知道阿姆达尔定律。不管怎样,了解阿姆达尔定律还是有用的。

我会首先以算术的方式介绍阿姆达尔定律定律,然后再用图表演示一下。

1、阿姆达尔定律定义

一个程序(或者一个算法)可以按照是否可以被并行化分为下面两个部分:

  • 可以被并行化的部分
  • 不可以被并行化的部分

假设一个程序处理磁盘上的文件。这个程序的一小部分用来扫描路径和在内存中创建文件目录。做完这些后,每个文件交个一个单独的线程去处理。扫描路径和创建文件目录的部分不可以被并行化,不过处理文件的过程可以。

程序串行(非并行)执行的总时间我们记为T。时间T包括不可以被并行和可以被并行部分的时间不可以被并行的部分我们记为B。那么可以被并行的部分就是T-B。下面的列表总结了这些定义:

  • T = 串行执行的总时间
  • B = 不可以并行的总时间
  • T-B = 并行部分的总时间

从上面可以得出:T = B + (T – B)

首先,这个看起来可能有一点奇怪,程序的可并行部分在上面这个公式中并没有自己的标识。然而,由于这个公式中可并行可以用总时间T 和 B(不可并行部分)表示出来,这个公式实际上已经从概念上得到了简化,也即是指以这种方式减少了变量的个数。

T-B 是可并行化的部分,以并行的方式执行可以提高程序的运行速度。可以提速多少取决于有多少线程或者多少个CPU来执行。线程或者CPU的个数我们记为N。可并行化部分被执行的最快时间可以通过下面的公式计算出来:(T – B ) / N 或者通过这种方式 (1 / N) * (T – B)。维基中使用的是第二种方式。

根据阿姆达尔定律,当一个程序的可并行部分使用N个线程或CPU执行时,执行的总时间为:T(N) = B + ( T – B ) / N

T(N)指的是在并行因子为N时的总执行时间。因此,T(1)就执行在并行因子为1时程序的总执行时间。使用T(1)代替T,阿姆达尔定律定律看起来像这样:T(N) = B + (T(1) – B) / N 表达的意思都是是一样的。

2、一个计算例子

为了更好的理解阿姆达尔定律,让我们来看一个计算的例子。执行一个程序的总时间设为1,程序的不可并行化占40%,按总时间1计算,就是0.4,可并行部分就是1 – 0.4 = 0.6。

在并行因子为2的情况下,程序的执行时间将会是:

1
2
3
4
T(2) = 0.4 + ( 1 - 0.4 ) / 2
= 0.4 + 0.6 / 2
= 0.4 + 0.3
= 0.7

在并行因子为5的情况下,程序的执行时间将会是:

1
2
3
4
T(5) = 0.4 + ( 1 - 0.4 ) / 5
= 0.4 + 0.6 / 6
= 0.4 + 0.12
= 0.52

3、阿姆达尔定律图示

为了更好地理解阿姆达尔定律,我会尝试演示这个定律是如何诞生的。

首先,一个程序可以被分割为两部分,一部分为不可并行部分B,一部分为可并行部分1 – B。如下图:

image-20210814195444348

在顶部被带有分割线的那条直线代表总时间 T(1)。

下面你可以看到在并行因子为2的情况下的执行时间:

image-20210814195510264

并行因子为3的情况:

image-20210814195531931

4、优化算法

从阿姆达尔定律可以看出,程序的可并行化部分可以通过使用更多的硬件(更多的线程或CPU)运行更快。对于不可并行化的部分,只能通过优化代码来达到提速的目的。因此,你可以通过优化不可并行化部分来提高你的程序的运行速度和并行能力。你可以对不可并行化在算法上做一点改动,如果有可能,你也可以把一些移到可并行化放的部分。

优化串行分量

如果你优化一个程序的串行化部分,你也可以使用阿姆达尔定律来计算程序优化后的执行时间。如果不可并行部分通过一个因子O来优化,那么阿姆达尔定律看起来就像这样:

1
T(O, N) = B / O + (1 - B / O) / N

记住,现在程序的不可并行化部分占了B / O的时间,所以,可并行化部分就占了1 - B / O的时间。

如果B为0.1,O为2,N为5,计算看起来就像这样:

1
2
3
4
5
6
T(2,5) = 0.4 / 2 + (1 - 0.4 / 2) / 5
= 0.2 + (1 - 0.4 / 2) / 5
= 0.2 + (1 - 0.2) / 5
= 0.2 + 0.8 / 5
= 0.2 + 0.16
= 0.36

5、运行时间 vs. 加速

到目前为止,我们只用阿姆达尔定律计算了一个程序或算法在优化后或者并行化后的执行时间。我们也可以使用阿姆达尔定律计算加速比(speedup),也就是经过优化后或者串行化后的程序或算法比原来快了多少。

如果旧版本的程序或算法的执行时间为T,那么增速比就是:

1
Speedup = T / T(O , N);

为了计算执行时间,我们常常把T设为1,加速比为原来时间的一个分数。公式大致像下面这样:

1
Speedup = 1 / T(O,N)

如果我们使用阿姆达尔定律来代替T(O,N),我们可以得到下面的公式:

1
Speedup = 1 / ( B / O + (1 - B / O) / N)

如果B = 0.4, O = 2, N = 5, 计算变成下面这样:

1
2
3
4
5
6
7
Speedup = 1 / ( 0.4 / 2 + (1 - 0.4 / 2) / 5)
= 1 / ( 0.2 + (1 - 0.4 / 2) / 5)
= 1 / ( 0.2 + (1 - 0.2) / 5 )
= 1 / ( 0.2 + 0.8 / 5 )
= 1 / ( 0.2 + 0.16 )
= 1 / 0.36
= 2.77777 ...

上面的计算结果可以看出,如果你通过一个因子2来优化不可并行化部分,一个因子5来并行化可并行化部分,这个程序或算法的最新优化版本最多可以比原来的版本快2.77777倍。

6、测量,不要仅是计算

虽然阿姆达尔定律允许你并行化一个算法的理论加速比,但是不要过度依赖这样的计算。在实际场景中,当你优化或并行化一个算法时,可以有很多的因子可以被考虑进来。

内存的速度,CPU缓存,磁盘,网卡等可能都是一个限制因子。如果一个算法的最新版本是并行化的,但是导致了大量的CPU缓存浪费,你可能不会再使用x N个CPU来获得x N的期望加速。如果你的内存总线(memory bus),磁盘,网卡或者网络连接都处于高负载状态,也是一样的情况。

我们的建议是,使用阿姆达尔定律定律来指导我们优化程序,而不是用来测量优化带来的实际加速比。记住,有时候一个高度串行化的算法胜过一个并行化的算法,因为串行化版本不需要进行协调管理(上下文切换),而且一个单个的CPU在底层硬件工作(CPU管道、CPU缓存等)上的一致性可能更好。


8、并发的相关多线程设计模式

1、两阶段终止(Two Phase Termination)

在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

1、错误思路

  • 使用线程对象的 stop() 方法停止线程
    • stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁,可能会造成死锁问题
  • 使用 System.exit(int) 方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序都停止

2、正常做法——两阶段终止模式(interrupt实现)

1、实现流程图

img

2、实现方法

interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行

  • 如果打断的是阻塞的线程,会清空打断状态,打断状态为false
  • 如果打断的是正常运行的线程,不会清空打断状态,打断状态为true
3、代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TestTPT{
public static void main(String[] args) {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start;
try{
Thread.sleep(3500);
} catch(InterruptedException e) {
e.printStackTrace();
}
tpt.stop();
}
}


class TwoPhaseTermination{

private Thread monitorThread;

// 启动监控线程
public void start(){
monitorThread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
// 是否被打断
if(current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(2000); // 情况1:sleep被打断
log.debug("执行监控记录"); // 情况2:正常执行被打断
} catch (InterruptedException e) {
// 因为sleep出现异常后,会清除打断标记
// 需要重置打断标记
current.interrupt();
e.printStackTrace();
}
}
}, "监控线程" );
monitorThread.start();
}

// 停止监控线程
public void stop() {
monitorThread.interrupt();
}
}

执行结果:

1
2
3
4
5
6
7
8
9
11:49:42.915 c.TwoPhaseTermination [监控线程] - 执行监控记录 
11:49:43.919 c.TwoPhaseTermination [监控线程] - 执行监控记录
11:49:44.919 c.TwoPhaseTermination [监控线程] - 执行监控记录
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at cn.itcast.test.TwoPhaseTermination.lambda$start$0(Iest3. java:30)
at java.lang.Thread.run(Thread.java: 748)
11:49:45.413 c.TestTwoPhaseTermination [main] - stop
11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事

两个细节:

  1. 线程被打断时分为两种情况
    1. 情况1:线程在sleep时被打断,此时线程会抛出InterruptedException: sleep interrupted异常进入catch模块,不会清除打断标记,也就是说isInterrupted()返回false,所以需要在catch模块当中重置打断标记
    2. 情况2:线程在正常执行被打断,此时线程的打断标记不会被清除,即isInterrupted()返回true,在下一次的判断中进入if块执行break;语句退出死循环
  2. 线程使用的是isInterrupted()用来判断打断标记是否为true,即有没有被打断过。其实还有一个方法可以用来判断有没有被打断过,那就是interrupted()
    1. isInterrupted():判断当前线程是否被打断,不会清除==打断标记==
    2. interrupted():判断当前线程是否被打断,是一个静态方法,会清除==打断标记==

3、正常做法——两阶段终止模式(volatile实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class TestTPT{
public static void main(String[] args) {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start;
try{
Thread.sleep(3500);
log.debug("停止监控");
} catch(InterruptedException e) {
e.printStackTrace();
}
tpt.stop();
}
}


class TwoPhaseTermination{

// 监控线程
private Thread monitorThread;
// 打断标记
private volatile boolean stop = false;

// 启动监控线程
public void start(){
monitorThread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
// 是否被打断
if(stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(2000); // 情况1:sleep被打断
log.debug("执行监控记录"); // 情况2:正常执行被打断
} catch (InterruptedException e) {
}
}
}, "监控线程" );
monitorThread.start();
}

// 停止监控线程
public void stop() {
stop = true;
//这里依旧使用打断interrupt是为了即使监控线程在sleep当中也能马上结束,而不是等到sleep结束在停止
monitorThread.interrupt();
}
}

执行结果:

1
2
3
4
5
17:08:21.970 c.TwoPhaseTermination [监控线程] - 执行监控记录 
17:08:22.973 c.TwoPhaseTermination [监控线程] - 执行监控记录
17:08:23.974 c.TwoPhaseTermination [监控线程] - 执行监控记录
17:08:24.467 c.TwoPhaseTermination [mian] - 停止监控
17:08:24.467 c.TwoPhaseTermination [监控线程] - 料理后事

2、犹豫模式(Balking)——同步模式

1、定义

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回

2、实现

以上面两阶段终止模式的例子,当调用了多次tpt.start;就会创建多个监控线程,其实这是错误的,监控线程只需要一个就够了,在第二次创建监控线程的时候应该直接返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.TwoPhaseTermination")
public class Test13 {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
tpt.start();
tpt.start();

/*Thread.sleep(3500);
log.debug("停止监控");
tpt.stop();*/
}
}

@Slf4j(topic = "c.TwoPhaseTermination")
class TwoPhaseTermination {
// 监控线程
private Thread monitorThread;
// 停止标记
private volatile boolean stop = false;
// 判断是否执行过 start 方法
private volatile boolean starting = false;

// 启动监控线程
public void start() {
synchronized (this) {
if (starting) { // false
return;
}
starting = true;
}
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 是否被打断
if (stop) {
log.debug("料理后事");
starting = false;
break;
}
try {
Thread.sleep(1000);
log.debug("执行监控记录");
} catch (InterruptedException e) {
}
}
}, "monitor");
monitorThread.start();
}

// 停止监控线程
public void stop() {
stop = true;
monitorThread.interrupt();
}
}

3、犹豫Balking模式还经常用来实现线程安全的单例

1
2
3
4
5
6
7
8
9
10
11
12
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}

对比一下保护性暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。

3、保护性暂停(Guarded Suspension)——同步模式

1、定义

保护性暂停(Guarded Suspension)用在一个线程等待另一个线程的执行结果

要点:

  • 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
  • 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
  • JDK 中,join 的实现、Future 的实现,采用的就是此模式
  • 因为要等待另一方的结果,因此归类到同步模式

image-20210805201137709

2、实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class GuardedObject {
// 结果
private Object response;

// 获取结果
public Object get(long timeout) {
synchronized (this) {
// 没有结果
while (response == null) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

3、应用

一个线程等待另一个线程的执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
// 等待结果
log.debug("等待结果");
List<String> list = (List<String>) guardedobject.get();
log.debug("结果大小: {}" list.size());
},"t1").start();

new Thread(() -> {
log.debug("执行下载");
try {
// 子线程执行下载
List<String> list = Downloader.download();
guardedObject.complete(response);
} catch (IOException e) {
e.printStackTrace();
}
},"t2").start();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Downloader {
public static List<String> download() throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL("https://www.baidu.com/").openConnection();
List<String> lines = new ArrayList<>();
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
}
return lines;
}
}

结果:

1
2
3
14:42:07.731 c.Test20 [t1] - 等待结果
14:42:07.731 c.Test20 [t2] - 执行下载
14:42:33.636 c.Test20 [t1] - 结果大小: 3

4、带超时版 GuardedObjec

如果要控制超时时间呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 增加超时效果
class GuardedObject {
// 结果
private Object response;

// 获取结果
// timeout 表示要等待多久 2000
public Object get(long timeout) {
synchronized (this) {
// 开始时间 15:00:00
long begin = System.currentTimeMillis();
// 经历的时间
long passedTime = 0;
while (response == null) {
// 这一轮循环应该等待的时间
long waitTime = timeout - passedTime;
// 经历的时间超过了最大等待时间时,退出循环
if (timeout - passedTime <= 0) {
break;
}
try {
this.wait(waitTime); // 虚假唤醒 15:00:01
} catch (InterruptedException e) {
e.printStackTrace();
}
// 求得经历时间
passedTime = System.currentTimeMillis() - begin; // 15:00:02 1s
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
log.debug("begin");
Object response = guardedobject.get(2000);
log.debug("结果大小: {}" response);
},"t1").start();

new Thread(() -> {
log.debug("begin");
// 睡眠1s
Sleeper.sleep(1);
guardedObject.complete(new Object());
},"t2").start();
}

如果线程t2睡眠1s,那么get没有超时,可以获得Object对象:

1
2
3
15:51:04.932 c.Test20 [Thread-1] - begin
15:51:04.932 c.Test20 [Thread-0] - begin
15:51:05.935 c.Test20 [Thread-0] - 结果是:java. lang .0bject@455b03c9

如果线程t2睡眠3s,那么get超时,不能获得Object对象:

1
2
3
15:52:07.993 c.Test20 [t2] - begin
15:52:07.993 c.Test20 [t1] - begin
15:52:09.997 c.Test20 [t1] - 结果是:null

测试虚假唤醒问题:把t2线程complete传入null(线程t2睡眠1s):(如果代码wait传入的是timeout而不是waitTime,这里的等待时间为3s(虚假唤醒1s,加设置的2s = 总共3s),而不是设置的2s)

1
2
3
15:52:11.975 c.Test20 [t2] - begin
15:52:11.975 c.Test20 [t1] - begin
15:52:13.979 c.Test20 [t1] - 结果是:null

5、扩展:多任务版 GuardedObject

代码:

图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员

如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理。

注意:这里的结果等待者和结果产生者是一一对应的,所以采用的是保护性暂停模式,如果不是一一对应的话,使用的是生产者消费者模式

image-20210805215400468

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import cn.itcast.n2.util.Sleeper;
import lombok.extern.slf4j.Slf4j;

import java.util.Hashtable;
import java.util.Map;
import java.util.Set;

@Slf4j(topic = "c.Test20")
public class Test20 {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 3; i++) {
// 生成3个居民
new People().start();
}
Sleeper.sleep(1);
for (Integer id : Mailboxes.getIds()) {
// 邮递员送信
new Postman(id, "内容" + id).start();
}
}
}

@Slf4j(topic = "c.People")
class People extends Thread{
@Override
public void run() {
// 收信
GuardedObject guardedObject = Mailboxes.createGuardedObject();
log.debug("开始收信 id:{}", guardedObject.getId());
Object mail = guardedObject.get(5000);
log.debug("收到信 id:{}, 内容:{}", guardedObject.getId(), mail);
}
}

@Slf4j(topic = "c.Postman")
class Postman extends Thread {
private int id;
private String mail;

public Postman(int id, String mail) {
this.id = id;
this.mail = mail;
}

@Override
public void run() {
GuardedObject guardedObject = Mailboxes.getGuardedObject(id);
log.debug("送信 id:{}, 内容:{}", id, mail);
guardedObject.complete(mail);
}
}

/**
* Mailboxes邮箱类是之间解耦类,并不与业务挂钩,代码可以复用
*/
class Mailboxes {
private static Map<Integer, GuardedObject> boxes = new Hashtable<>();

private static int id = 1;
// 产生唯一 id
// 加上synchronized保证生成id的线程安全
private static synchronized int generateId() {
return id++;
}

public static GuardedObject getGuardedObject(int id) {
// 注意这里使用的是remove方法,而不是get方法
// 因为这里的对应关系只需要存在一次,送完信就应该取消对应关系
// 如果没有取消对应关系的话,由于boxes是static类型,是不会进入垃圾回收的,
// 造成了内存泄漏,长期以往可能会导致OOM
return boxes.remove(id);
}

public static GuardedObject createGuardedObject() {
GuardedObject go = new GuardedObject(generateId());
boxes.put(go.getId(), go);
return go;
}

public static Set<Integer> getIds() {
return boxes.keySet();
}
}

// 增加超时效果
class GuardedObject {

// 标识 Guarded Object
private int id;

public GuardedObject(int id) {
this.id = id;
}

public int getId() {
return id;
}

// 结果
private Object response;

// 获取结果
// timeout 表示要等待多久 2000
public Object get(long timeout) {
synchronized (this) {
// 开始时间 15:00:00
long begin = System.currentTimeMillis();
// 经历的时间
long passedTime = 0;
while (response == null) {
// 这一轮循环应该等待的时间
long waitTime = timeout - passedTime;
// 经历的时间超过了最大等待时间时,退出循环
if (timeout - passedTime <= 0) {
break;
}
try {
this.wait(waitTime); // 虚假唤醒 15:00:01
} catch (InterruptedException e) {
e.printStackTrace();
}
// 求得经历时间
passedTime = System.currentTimeMillis() - begin; // 15:00:02 1s
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

6、使用保护性暂停的好处

  1. 如果使用的是一个线程(A)使用join来等待另外一个线程(B)的结果的话,如果线程B给线程A结果,但是线程A还不能接收,线程B就不能往下运行,必须等待线程A接收结果之后才能往下运行。
  2. 如果使用的是保护性暂停模式的话,线程B在结束下载以后还能往下运行代码,没必要等待线程A接收结果
  3. 因为join是线程结束才返回,但是阻塞的线程只需要那个response有值,凭什么要去等另一个线程全部执行完
  4. 使用join的话,两线程交互的结果只能设置成全局的,而使用保护性暂停模式,可以把等待的结果设置成局部的(如示例当中的list)

4、生产者消费者模式(Producer Consumer)——异步模式

1、定义

要点:

  • 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
  • 消费队列可以用来平衡生产和消费的线程资源
  • 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  • 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  • JDK 中各种阻塞队列,采用的就是这种模式

image-20210805231147322

2、实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 消息类 不设置set方法,加上整个类被final修饰,保证没有其他方法去修改Message里面的值
final class Message {
// 消息对应的id,用来辨识message,用在查看消息是否发送成功等等
private int id;
// 消息的内容
private Object message;
public Message(int id, Object message) {
this.id = id;
this.message = message;
}

public int getId() {
return id;
}
public Object getMessage() {
return message;
}
}

// 消息队列 java线程间通信
@Slf4j(topic = "c.MessageQueue")
class MessageQueue {
// 消息队列集合
private LinkedList<Message> list;
// 队列容量
private int capacity;

public MessageQueue(int capacity) {
this.capacity = capacity;
list = new LinkedList<>();
}

// 获取消息
public Message take() {
synchronized (list) {
// 检查队列是否为空
while (list.isEmpty()) {
try {
log.debug("队列为空,消费者线程等待");
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
//从队列头部获取消息并返回
Message message = queue.removeFirst();
log.debug("已消费消息{}", message);
list.notifyAll();
return message;
}
}

// 存入消息
public void put(Message message) {
synchronized (list) {
// 检查队列是否已满
while (list.size() == capacity) {
try {
log.debug("队列已满,生产者线程等待");
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
// 将消息加入队列尾部
list.addLast(message);
log.debug("已生产消息{}", message);
list.notifyAll();
}
}

3、应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
MessageQueue queue = new MessageQueue(2);
// 3 个生产者线程, 下载任务
for (int i = 0; i < 3; i++) {
// lambda表达式要求里面的变量为不可变的,不能直接写i,可以先将i赋值给id,在写入id
int id = i;
new Thread(() -> {
queue.put(new Message(id,"值"+id));
},"生产者" + i).start();
}

// 1个消费者线程,消费任务
new thread(() -> {
while(true) {
// 每隔1s消费一条消息
sleep(1);
Message message = queue.take();
}
},"消费者").start();
}

结果:

1
2
3
4
5
6
7
8
11:52:21.949 c.MessageQueue [生产者2] - 已生产消息Message{id=2, value=值2}
11:52:21.953 c.MessageQueue [生产者0] - 已生产消息Message{id=0, value=值0}
11:52:21.953 c.MessageQueue [生产者1] - 队列已满,生产者线程等待
11:52:22.948 c.MessageQueue [消费者] - 已消费消息Message{id=2, value=值2}
11:52:22.948 c.MessageQueue [生产者1] - 已生产消息Message{id=1, value=值1}
11:52:23.949 c.MessageQueue [消费者] - 已消费消息Message{id=0, value=值0}
11:52:24.949 c.MessageQueue [消费者] - 已消费消息Message{id=1, value=值1}
11:52:25.949 c.MessageQueue [消费者] - 队列为空,消费者线程等待

5、顺序控制(Sequence Control)——同步模式

1、固定运行顺序

比如,必须先 2 后 1 打印

1、wait notify 版

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 用来同步的对象
static final Object lock = new Object();
// t2 运行标记, 代表 t2 是否执行过
static boolean t2runed = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock) {
// 如果 t2 没有执行过
while (!t2runed) {
try {
// t1 先等一会
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("1");
}
},"t1").start();

Thread t2 = new Thread(() -> {
synchronized (lock) {
log.debug("1");
t2runed = true;
lock.notifyAll();
}
},"t2").start();
}

结果:

1
2
15:55:40.793 c.Test25[t2] - 2
15:55:40.796 c.Test25[t1] - 1

实际上使用ReentrantLock的await与signal方法与上面类似,这里不在展示。

2、Park Unpark 版

可以看到,实现上很麻烦:

  1. 首先,需要保证先 wait 再 notify,否则 wait 线程永远得不到唤醒。因此使用了『运行标记』来判断该不该 wait
  2. 第二,如果有些干扰线程错误地 notify 了 wait 线程,条件不满足时还要重新等待,使用了 while 循环来解决此问题(虚假唤醒问题)
  3. 最后,唤醒对象上的 wait 线程需要使用 notifyAll,因为『同步对象』上的等待线程可能不止一个

可以使用 LockSupport 类的 park 和 unpark 来简化上面的题目:代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.Test26")
public class Test26 {
public static void main(String[] args) {

Thread t1 = new Thread(() -> {
LockSupport.park();
log.debug("1");
}, "t1");
t1.start();

new Thread(() -> {
log.debug("2");
LockSupport.unpark(t1);
},"t2").start();
}
}

结果:

1
2
16:02:56.652 c.Test26[t2] - 2
16:02:56.655 c.Test26[t1] - 1

park 和 unpark 方法比较灵活,他俩谁先调用,谁后调用无所谓。并且是以线程为单位进行『暂停』和『恢复』,不需要『同步对象』和『运行标记』

2、交替输出

线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。现在要求输出 abcabcabcabcabc 怎么实现(与线程间定制化通信有区别)

1、wait notify 版

需要借助等待标记来知道下一个唤醒的线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.Test27")
public class Test27 {
public static void main(String[] args) {
WaitNotify wn = new WaitNotify(1, 5);
new Thread(() -> {
wn.print("a", 1, 2);
}).start();
new Thread(() -> {
wn.print("b", 2, 3);
}).start();
new Thread(() -> {
wn.print("c", 3, 1);
}).start();
}
}

/*
输出内容 等待标记 下一个标记
a 1 2
b 2 3
c 3 1
*/
class WaitNotify {
// 打印 a 1 2
public void print(String str, int waitFlag, int nextFlag) {
for (int i = 0; i < loopNumber; i++) {
synchronized (this) {
while(flag != waitFlag) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print(str);
flag = nextFlag;
this.notifyAll();
}
}
}

// 等待标记
private int flag; // 2
// 循环次数
private int loopNumber;

public WaitNotify(int flag, int loopNumber) {
this.flag = flag;
this.loopNumber = loopNumber;
}
}
2、Lock 条件变量版

Lock就不需要借助等待标记,但是需要主线程来启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import sun.rmi.runtime.Log;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class Test30 {
public static void main(String[] args) throws InterruptedException {
AwaitSignal awaitSignal = new AwaitSignal(5);
Condition a = awaitSignal.newCondition();
Condition b = awaitSignal.newCondition();
Condition c = awaitSignal.newCondition();
new Thread(() -> {
awaitSignal.print("a", a, b);
}).start();
new Thread(() -> {
awaitSignal.print("b", b, c);
}).start();
new Thread(() -> {
awaitSignal.print("c", c, a);
}).start();

Thread.sleep(1000);
awaitSignal.lock();
try {
System.out.println("开始...");
a.signal();
} finally {
awaitSignal.unlock();
}

}
}

class AwaitSignal extends ReentrantLock{
private int loopNumber;

public AwaitSignal(int loopNumber) {
this.loopNumber = loopNumber;
}
// 参数1 打印内容, 参数2 进入哪一间休息室, 参数3 下一间休息室
public void print(String str, Condition current, Condition next) {
for (int i = 0; i < loopNumber; i++) {
lock();
try {
current.await();
System.out.print(str);
next.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unlock();
}
}
}
}

注意:该实现没有考虑 a,b,c 线程都就绪再开始

3、Park Unpark 版

依旧需要主线程来启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.Test31")
public class Test31 {

static Thread t1;
static Thread t2;
static Thread t3;
public static void main(String[] args) {
ParkUnpark pu = new ParkUnpark(5);
t1 = new Thread(() -> {
pu.print("a", t2);
});
t2 = new Thread(() -> {
pu.print("b", t3);
});
t3 = new Thread(() -> {
pu.print("c", t1);
});
t1.start();
t2.start();
t3.start();

LockSupport.unpark(t1);
}
}

class ParkUnpark {
public void print(String str, Thread next) {
for (int i = 0; i < loopNumber; i++) {
LockSupport.park();
System.out.print(str);
LockSupport.unpark(next);
}
}

private int loopNumber;

public ParkUnpark(int loopNumber) {
this.loopNumber = loopNumber;
}
}

6、享元模式(Flyweight pattern)

1、简介

定义 英文名称:Flyweight pattern. 当需要重用数量有限的同一类对象时

比如说String,为了保证String不可变性,String在进行操作的时候经常使用的方法是:保护性拷贝。这种方式有个缺点:当拷贝的内容相当大的时候,这个时候对系统的性能以及内存的状态是非常不利的,这个时候就需要使用享元模式了

wikipedia: A flyweight is an object that minimizes memory usage by sharing as much data as possible with other similar objects

2、体现

1、包装类

在JDK中 BooleanByteShortIntegerLongCharacter 等包装类提供了 valueOf 方法,例如 Long 的valueOf **==会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象==**,大于这个范围,才会新建 Long 对象:

1
2
3
4
5
6
7
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}

LongCache的初始化:

1
2
3
4
5
6
7
8
9
10
private static class LongCache {
private LongCache(){}

static final Long cache[] = new Long[-(-128) + 127 + 1];

static {
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}

注意:

  • Byte, Short, Long 缓存的范围都是 -128~127
  • Character 缓存的范围是 0~127
  • Integer的默认范围是 -128~127
    • 最小值不能变
    • 但最大值可以通过调整虚拟机参数 -Djava.lang.Integer.IntegerCache.high 来改变
  • Boolean 缓存了 TRUE 和 FALSE
2、String 串池

在JVM的StringTable具体说明

3、BigDecimal BigInteger

注意:BigDecimal BigInteger的单个方法是线程安全的,但是方法之间组合组合不一定是线程安全的(有时候使用AutomicIntrger等等原子类来保证它们在组合下的线程安全)

3、DIY

例如:一个线上商城应用,QPS 达到数千,如果每次都重新创建和关闭数据库连接,性能会受到极大影响。 这时预先创建好一批连接,放入连接池。一次请求到达后,从连接池获取连接,使用完毕后再还回连接池,这样既节约了连接的创建和关闭时间,也实现了连接的重用,不至于让庞大的连接数压垮数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
import lombok.extern.slf4j.Slf4j;

import java.sql.*;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicIntegerArray;

public class Test3 {
public static void main(String[] args) {
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
}
}

@Slf4j(topic = "c.Pool")
class Pool {
// 1. 连接池大小
private final int poolSize;

// 2. 连接对象数组
private Connection[] connections;

// 3. 连接状态数组 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;

// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i+1));
}
}

// 5. 借连接
public Connection borrow() {
while(true) {
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 如果没有空闲连接,当前线程进入等待
synchronized (this) {
try {
log.debug("wait...");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
synchronized (this) {
log.debug("free {}", conn);
this.notifyAll();
}
break;
}
}
}
}

class MockConnection implements Connection {

private String name;

public MockConnection(String name) {
this.name = name;
}

@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}

@Override
public Statement createStatement() throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql) throws SQLException {
return null;
}

@Override
public String nativeSQL(String sql) throws SQLException {
return null;
}

@Override
public void setAutoCommit(boolean autoCommit) throws SQLException {

}

@Override
public boolean getAutoCommit() throws SQLException {
return false;
}

@Override
public void commit() throws SQLException {

}

@Override
public void rollback() throws SQLException {

}

@Override
public void close() throws SQLException {

}

@Override
public boolean isClosed() throws SQLException {
return false;
}

@Override
public DatabaseMetaData getMetaData() throws SQLException {
return null;
}

@Override
public void setReadOnly(boolean readOnly) throws SQLException {

}

@Override
public boolean isReadOnly() throws SQLException {
return false;
}

@Override
public void setCatalog(String catalog) throws SQLException {

}

@Override
public String getCatalog() throws SQLException {
return null;
}

@Override
public void setTransactionIsolation(int level) throws SQLException {

}

@Override
public int getTransactionIsolation() throws SQLException {
return 0;
}

@Override
public SQLWarning getWarnings() throws SQLException {
return null;
}

@Override
public void clearWarnings() throws SQLException {

}

@Override
public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public Map<String, Class<?>> getTypeMap() throws SQLException {
return null;
}

@Override
public void setTypeMap(Map<String, Class<?>> map) throws SQLException {

}

@Override
public void setHoldability(int holdability) throws SQLException {

}

@Override
public int getHoldability() throws SQLException {
return 0;
}

@Override
public Savepoint setSavepoint() throws SQLException {
return null;
}

@Override
public Savepoint setSavepoint(String name) throws SQLException {
return null;
}

@Override
public void rollback(Savepoint savepoint) throws SQLException {

}

@Override
public void releaseSavepoint(Savepoint savepoint) throws SQLException {

}

@Override
public Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException {
return null;
}

@Override
public Clob createClob() throws SQLException {
return null;
}

@Override
public Blob createBlob() throws SQLException {
return null;
}

@Override
public NClob createNClob() throws SQLException {
return null;
}

@Override
public SQLXML createSQLXML() throws SQLException {
return null;
}

@Override
public boolean isValid(int timeout) throws SQLException {
return false;
}

@Override
public void setClientInfo(String name, String value) throws SQLClientInfoException {

}

@Override
public void setClientInfo(Properties properties) throws SQLClientInfoException {

}

@Override
public String getClientInfo(String name) throws SQLException {
return null;
}

@Override
public Properties getClientInfo() throws SQLException {
return null;
}

@Override
public Array createArrayOf(String typeName, Object[] elements) throws SQLException {
return null;
}

@Override
public Struct createStruct(String typeName, Object[] attributes) throws SQLException {
return null;
}

@Override
public void setSchema(String schema) throws SQLException {

}

@Override
public String getSchema() throws SQLException {
return null;
}

@Override
public void abort(Executor executor) throws SQLException {

}

@Override
public void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException {

}

@Override
public int getNetworkTimeout() throws SQLException {
return 0;
}

@Override
public <T> T unwrap(Class<T> iface) throws SQLException {
return null;
}

@Override
public boolean isWrapperFor(Class<?> iface) throws SQLException {
return false;
}
}

测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
15:26:48.211 c.Pool [THread-3] - wait...
15:26:48.211 c.Pool [Thread-0] - borrow MockConnection{name= '连接1'}
15:26:48.215 c.Pool [Thread-4] - wait...
15:26:48.215 c.Pool [Thread-2] - wait...
15:26:48.211 c.Pool [Thread-1] - borrow MockConnection{name= '连接2'}
15:26:48.397 c.Pool [Thread-0] - free MockConnection{name= '连接1'}
15:26:48.397 c.Pool [Thread-4] - wait...
15:26:48.397 c.Pool [Thread-2] - borrow MockConnection{name= '连接1'}
15:26:48.397 c.Pool [Thread-3] - wait...
15:26:48.412 c.Pool [Thread-1] - free MockConnection{name=' 连接2'}
15:26:48.412 c.Pool [Thread-3] - borrow MockConnection{name= '连接2'}
15:26:48.412 c.Pool [Thread-4] - wait...
15:26:48.796 c.Pool [Thread-3] - free MockConnection{name= '连接2'}
15:26:48.796 c.Pool [Thread-4] - borrow MockConnection{name= '连接2'}
15:26:49.340 c.Pool [Thread-2] - free MockConnection{name=' 连接1'}
15:26:49.561 c.Pool [Thread-4] - free MockConnection{name= '连接2'}

以上实现没有考虑:

  • 连接的动态增长与收缩
  • 连接保活(可用性检测)
  • 等待超时处理
  • 分布式 hash

对于关系型数据库,有比较成熟的连接池实现,例如c3p0, druid等 对于更通用的对象池,可以考虑使用apache commons pool(redis使用),例如redis连接池可以参考jedis中关于连接池的实现。

7、工作线程模式(Worker Thread)——异步模式

1、定义

让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现就是线程池,也体现了经典设计模式中的享元模式

例如,海底捞的服务员(线程),轮流处理每位客人的点餐(任务),如果为每位客人都配一名专属的服务员,那么成本就太高了(对比另一种多线程设计模式:Thread-Per-Message

注意:不同任务类型应该使用不同的线程池,这样能够避免饥饿,并能提升效率

例如,如果一个餐馆的工人既要招呼客人(任务类型A),又要到后厨做菜(任务类型B)显然效率不咋地,分成服务员(线程池A)与厨师(线程池B)更为合理,当然你能想到更细致的分工

2、饥饿

固定大小线程池会有饥饿现象:

  • 两个工人是同一个线程池中的两个线程
  • 他们要做的事情是:为客人点餐和到后厨做菜,这是两个阶段的工作
    • 客人点餐:必须先点完餐,等菜做好,上菜,在此期间处理点餐的工人必须等待
    • 后厨做菜:没啥说的,做就是了
  • 比如工人A 处理了点餐任务,接下来它要等着 工人B 把菜做好,然后上菜,他俩也配合的蛮好
  • 但现在同时来了两个客人,这个时候工人A 和工人B 都去处理点餐了,这时没人做饭了,饥饿
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

@Slf4j(topic = "c.TestDeadLock")
public class TestStarvation {

static final List<String> MENU = Arrays.asList("地三鲜", "宫保鸡丁", "辣子鸡丁", "烤鸡翅");
static Random RANDOM = new Random();
static String cooking() {
return MENU.get(RANDOM.nextInt(MENU.size()));
}
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);

pool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});
pool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});

}
}

输出:

1
2
15:28:41.386 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:28:41.386 c.TestDeadLock [pool-1-thread-2] - 处理点餐...

解决方法:可以增加线程池的大小,不过不是根本解决方案,还是前面提到的,不同的任务类型,采用不同的线程池,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

@Slf4j(topic = "c.TestDeadLock")
public class TestStarvation {

static final List<String> MENU = Arrays.asList("地三鲜", "宫保鸡丁", "辣子鸡丁", "烤鸡翅");
static Random RANDOM = new Random();
static String cooking() {
return MENU.get(RANDOM.nextInt(MENU.size()));
}
public static void main(String[] args) {
ExecutorService waiterPool = Executors.newFixedThreadPool(1);
ExecutorService cookPool = Executors.newFixedThreadPool(1);

waiterPool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});
waiterPool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});

}
}

输出:

1
2
3
4
5
6
15:33:14.925 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:33:14.928 c.TestDeadLock [pool-2-thread-1] - 做菜
15:33:14.929 c.TestDeadLock [pool-1-thread-1] - 上菜: 辣子鸡丁
15:33:14.931 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:33:14.931 c.TestDeadLock [pool-2-thread-1] - 做菜
15:33:14.931 c.TestDeadLock [pool-1-thread-1] - 上菜: 宫保鸡丁

3、创建多少线程池合适

  • 过小会导致程序不能充分地利用系统资源、容易导致饥饿
  • 过大会导致更多的线程上下文切换,占用更多内存
1、CPU 密集型运算

通常采用 cpu 核数 + 1 能够实现最优的 CPU 利用率,**+1 是保证当线程由于页缺失故障(操作系统)或其它原因导致暂停时,额外的这个线程就能顶上去,保证 CPU 时钟周期不被浪费**

2、I/O 密集型运算

CPU 不总是处于繁忙状态,例如,当你执行业务计算时,这时候会使用 CPU 资源,但当你执行 I/O 操作时、远程RPC 调用时,包括进行数据库操作时,这时候 CPU 就闲下来了,你可以利用多线程提高它的利用率。

经验公式:线程数 = 核数 * 期望 CPU 利用率 * 总时间(CPU计算时间+等待时间) / CPU 计算时间

例如 4 核 CPU 计算时间是 50% ,其它等待时间是 50%,期望 cpu 被 100% 利用,套用公式:4 * 100% * 100% / 50% = 8

例如 4 核 CPU 计算时间是 10% ,其它等待时间是 90%,期望 cpu 被 100% 利用,套用公式:4 * 100% * 100% / 10% = 40

4、自定义线程池

具体参考7、JUC当中的15、ThreadPool线程池的9、自定义线程池

5、不可变(Immutability)模式

如果对象一旦被创建,状态就不会再发生任何变化,并且只允许存在只读方法,这个对象就是不可变对象。利用不可变对象解决并发问题的模式,就是不可变模式。快速实现具备不可变性的类时,将类设置成final,类内的所有属性设置成final,只暴露只读方法即可。

经常用到的String对象和各种基础类型的包装类,比如,Long,Integer都具备不可变性。更进一步,基本数据类型的包装类都用到了享元模式(Flyweight Pattern),即在JVM启动时,创建一个对象池,当创建包装类型的对象时,首先查找对象池是否存在,如果不存在,才会创建新对象,并将其放入对象池中。比如,Long对象就默认缓存了[-128,127]之间的对象。几乎所有用到了享元模式的对象,比如,包装类对象,都不适合做锁,因为看上去是私有的这些对象,其实是共用的,会导致并发问题。

但是在使用不可变模式时,一定要搞清楚特定不可变对象的边界在哪里。比如,一个final类C的final成员变量a,当a的内部存在非final的其他对象时,并且C中存在着get_a的public接口,那么C就不是线程安全的。

6、Copy-on-Write模式

Copy-on-Write模式适用于对数据的实时性不敏感,读多写少且对读性能要求极为苛刻的小数据场景。

具体的实现也很简单,当数据需要修改时,先复制一份出来,在复制的数据上进行修改,并发读还是在旧的数据上,当数据修改完成后,再将老数据替换为修改后的新数据即可。但需要注意的是,当发生并发写时,可以使用CAS的策略来完成。

7、线程本地存储

Java语言提供ThreadLocal实现避免共享,即每个线程拥有自己的一份数据,线程之间没有竞争关系。

它的具体实现原理有点反直觉,因为ThreadLocal本质上仅仅是一个代理工具类,真正的数据存储在Thread类中。即,当ThreadLocal.get()获取线程本地数据时,通过Thread.currentThread().threadLocals来获取线程内真正的本地对象进行操作。

这种设计方式,从业务上看,线程的本地数据存在线程内部显然更合理,更重要的是,这样做不容易产生内存泄漏,因为线程本地对象和线程同生命周期,当线程被gc时,其数据也同样可以被gc掉。

但需要注意的是,在线程池的场景中,因为线程池中的线程通常与进程是同生共死的,即使线程本地变量的生命周期已经结束了,但因为该线程池尚未被释放,数据也是无法被回收的。因此,在这种场景下,ThreadLocal方案要小心使用。

8、Thread-Per-Message

现实世界中,很多事情需要委托他人办理,同样的场景,在并发编程领域,就是Thread-Per_message模式,简而言之,就是由一个线程接收任务,并发的为每一个收到的任务分配一个独立线程,这是最简单的分工方法,实现起来也非常简单。

线程在Java中是成本非常高的对象,本质上并不适合高并发场景。但是,换个角度思考,语言,工具和框架本身应该是帮助我们更敏捷的实现稳定可靠的方案,Thread-Per-Message是一种最简单的分工方法,Java语言支持不了,显然是Java语言本身的问题。

在Go语言中,存在一种轻量级线程,即协程的方案。在协程的架构下,Thread-Per-Message模式就完全没有问题了。


参考文档

Juc_并发编程目录

透彻理解Java并发编程系列

Java 全栈知识体系

Java 内存模型详解

面试官:说一下公平锁和非公平锁的区别?

ReentrantLock中的公平锁可能并不是真正意义上的公平

可重入锁

死锁面试题(什么是死锁,产生死锁的原因及必要条件)

乐观锁、悲观锁

什么是乐观锁,什么是悲观锁

本文主要参考自泰迪的bagwell的https://www.jianshu.com/p/32a15ef2f1bf和https://www.jianshu.com/p/6a14d0b54b8d,在此基础上参考了如下文章

推荐阅读ForkJoinPool的作者Doug Lea的一篇文章《A Java Fork/Join Framework》英文原文地址

JUC

Java并发编程实战(三:并发设计模式)

黑马程序员全面深入学习Java并发编程,JUC并发编程全套教程

【尚硅谷】大厂必备技术之JUC并发编程2021最新版

一、五大算法

0、穷举法

穷举法简单粗暴,没有什么问题是搞不定的,只要你肯花时间。同时对于小数据量,穷举法就是最优秀的算法。

1、贪婪算法

贪婪算法可以获取到问题的局部最优解,不一定能获取到全局最优解,同时获取最优解的好坏要看贪婪策略的选择。特点就是简单,能获取到局部最优解。同样是贪婪算法,不同的贪婪策略会导致得到差异非常大的结果。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/51417211

2、动态规划算法

当最优化问题具有重复子问题和最优子结构的时候,就是动态规划出场的时候了。动态规划算法的核心就是提供了一个memory来缓存重复子问题的结果,避免了递归的过程中的大量的重复计算。动态规划算法的难点在于怎么将问题转化为能够利用动态规划算法来解决。当重复子问题的数目比较小时,动态规划的效果也会很差。如果问题存在大量的重复子问题的话,那么动态规划对于效率的提高是非常恐怖的。

具体的详细解析请参见下面的文章:

3、分治算法(divide and conquer)

分治算法的逻辑更简单了,就是一个词,分而治之。分治算法就是把一个大的问题分为若干个子问题,然后在子问题继续向下分,一直到base cases,通过base cases的解决,一步步向上,最终解决最初的大问题。分治算法是递归的典型应用。

1、基本概念

正如名字divide and conquer所言,分治算法分为两步,一步是divide,一步是conquer。

Divide:Smaller Problems are solved recursively except base cases.

Conquer:The solution to the original problem is then formed from the solutions to the sub-problem.

在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……

任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,越容易直接求解,解题所需的计算时间也越少。例如,对于n个元素的排序问题,当n=1时,不需任何计算。n=2时,只要作一次比较即可排好序。n=3时只要作3次比较即可,……。而当n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。

2、基本思想及策略

分治法的设计思想是:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之

分治策略是:对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。这种算法设计策略叫做分治法。

如果原问题可分割成k个子问题,1<k≤n,且这些子问题都可解并可利用这些子问题的解求出原问题的解,那么这种分治法就是可行的。由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。

3、分治法适用的情况

分治法所能解决的问题一般具有以下几个特征:

  1. 该问题的规模缩小到一定的程度就可以容易地解决
  2. 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质
  3. 利用该问题分解出的子问题的解可以合并为该问题的解;
  4. 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题

第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;

第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;

第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法动态规划法

第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好

4、分治法的基本步骤

分治法在每一层递归上都有三个步骤:

  1. step1 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
  2. step2 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
  3. step3 合并:将各个子问题的解合并为原问题的解。

它的一般的算法设计模式如下: Divide-and-Conquer(P)

  1. if |P|≤n0

  2. then return(ADHOC(P))

  3. 将P分解为较小的子问题 P1 ,P2 ,…,Pk

  4. for i ← 1 to k

  5. do yi ← Divide-and-Conquer(Pi) △ 递归解决Pi

  6. T ← MERGE(y1,y2,…,yk) △ 合并子问题

  7. return(T)

其中|P|表示问题P的规模;n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。ADHOC(P)是该分治法中的基本子算法,用于直接解小规模的问题P。因此,当P的规模不超过n0时直接用算法ADHOC(P)求解。算法MERGE(y1,y2,…,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2 ,…,Pk的相应的解y1,y2,…,yk合并为P的解。

5、分治排序的运行时间问题及复杂度分析

img

一个分治法将规模为n的问题分成k个规模为n/m的子问题去解。设分解阀值n0=1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用merge将k个子问题的解合并为原问题的解需用f(n)个单位时间。用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有: T(n) = k*T(n/m)+f(n)

通过迭代法求得方程的解:

递归方程及其解只给出n等于m的方幂时T(n)的值,但是如果认为T(n)足够平滑,那么由n等于m的方幂时T(n)的值可以估计T(n)的增长速度。通常假定T(n)是单调上升的,从而当:mi≤n<mi+1时,T(mi)≤T(n)<T(mi+1)。

6、可使用分治法求解的一些经典问题

  1. 二分搜索
  2. 大整数乘法
  3. Strassen矩阵乘法
  4. 棋盘覆盖
  5. 合并排序
  6. 快速排序
  7. 线性时间选择
  8. 最接近点对问题
  9. 循环赛日程表
  10. 汉诺塔

7、依据分治法设计程序时的思维过程

实际上就是类似于数学归纳法,找到解决本问题的求解方程公式,然后根据方程公式设计递归程序。

  1. 一定是先找到最小问题规模时的求解方法
  2. 然后考虑随着问题规模增大时的求解方法
  3. 找到求解的递归函数式后(各种规模或因子),设计递归程序即可。

8、具体问题分析(java)

9、总结

分治算法的一个核心在于子问题的规模大小是否接近,如果接近则算法效率较高。

分治算法和动态规划都是解决子问题,然后对解进行合并;但是分治算法是寻找远小于原问题的子问题(因为对于计算机来说计算小数据的问题还是很快的),同时分治算法的效率并不一定好,而动态规划的效率取决于子问题的个数的多少,子问题的个数远小于子问题的总数的情况下(也就是重复子问题多),算法才会很高效。

10、具体的详细解析请参见下面的文章

4、回溯算法

回溯算法是深度优先策略的典型应用,回溯算法就是沿着一条路向下走,如果此路不同了,则回溯到上一个分岔路,在选一条路走,一直这样递归下去,直到遍历万所有的路径。八皇后问题是回溯算法的一个经典问题,还有一个经典的应用场景就是迷宫问题。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/17354461

5、 分支限界算法

回溯算法是深度优先,那么分支限界法就是广度优先的一个经典的例子。回溯法一般来说是遍历整个解空间,获取问题的所有解,而分支限界法则是获取一个解(一般来说要获取最优解)。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/17102037

二、排序

三、查找

1、Json的几个注解

pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.9.0</version>
</dependency>

@JsonFormat与@Date TimeFormat注解的使用

@JsonFormat

作用:从数据库获取时间传到前端进行展示的时候,我们有时候可能无法得到一个满意的时间格式的时间日期,在数据库中显示的是正确的时间格式,获取出来却变成了很丑的时间戳。@JsonFormat注解很好的解决了这个问题。

使用:

1
2
3
//设置时区为上海时区,时间格式自己据需求定。
@JsonFormat(pattern="yyyy-MM-dd",timezone = "GMT+8")
private Date testTime;

这里解释一下:@JsonFormat(pattern=”yyyy-MM-dd”,timezone = “GMT+8”)

  • pattern:是你需要转换的时间日期的格式
  • timezone:是时间设置为东八区,避免时间在转换中有误差

提示:@JsonFormat注解可以在属性的上方,同样可以在属性对应的get方法上,两种方式没有区别

完成上面两步之后,我们用对应的实体类来接收数据库查询出来的结果时就完成了时间格式的转换,再返回给前端时就是一个符合我们设置的时间格式了

@Date TimeFormat

pom.xml:

1
2
3
4
5
6
<!-- joda-time -->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.3</version>
</dependency>

作用:我们在使用WEB服务的时,可能会需要用到,传入时间给后台,比如注册新用户需要填入出生日期等,这个时候前台传递给后台的时间格式同样是不一致的,@DataTimeFormat便很好的解决了这个问题。

使用:在controller层我们使用spring mvc 表单自动封装映射对象时,我们在对应的接收前台数据的对象的属性上加@DateTimeFormat

1
2
3
@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone="GMT+8")
private Date symstarttime;

我这里就只贴这两个属性了,这里我两个注解都同时使用了,因为我既需要取数据到前台,也需要前台数据传到后台,都需要进行时间格式的转换,可以同时使用。

总结:

  • 注解@JsonFormat主要是后台到前台的时间格式的转换
  • 注解@DataFormAT主要是前后到后台的时间格式的转换

资料来源:@JsonFormat与@DateTimeFormat注解的使用

@JsonProperty使用详解

作用:@JsonProperty注解主要用于实体类的属性上,作用可以简单的理解为在反序列化的时候给属性重命名(多一个名字来识别)

使用:

1
2
@JsonProperty(value = "fake_name", required = true)
private String fakeName;

注意:

  • 使用JSON.toJsonString的时候实体类需要有getter方法,否则会输出{}
  • @requestBody注解需要在post请求下才能正常使用.

资料来源:@JsonProperty使用详解

@JsonInclude

作用:

  • JsonJsonInclude.Include.ALWAYS 这个是默认策略,任何情况下都序列化该字段,和不写这个注解是一样的效果。
  • JsonJsonInclude.Include.NON_NULL这个最常用,即如果加该注解的字段为null,那么就不序列化这个字段了。
  • JsonJsonInclude.Include.NON_ABSENT这个包含NON_NULL,即为null的时候不序列化。

使用:

1
2
@JsonInclude(JsonInclude.Include.NON_NULL)
private String username;

资料来源:jackSon中@JsonInclude注解详解

@JsonIgnore注解

作用:在json序列化时将pojo中的一些属性忽略掉,标记在属性或者方法上,返回的json数据即不包含该属性。

使用:

1
2
@JsonIgnore
private String password;// 密码

资料来源:@JsonIgnore注解

2、ip2region——Java 根据 IP 地址来获取位置

pom.xml:

1
2
3
4
5
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>1.7.2</version>
</dependency>

然后下载 IP库 ip2region.db: https://gitee.com/lionsoul/ip2region/tree/master/data

下载解压后只需要 data 目录下的 ip2region.db 就可以了 .

把 ip2region.db 复制到 maven 项目的 resources 目录下.

然后具体实现,可以把以下代码封装成方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Ip2RegionTest {
public static void main(String[] args){
//ip
String ip="220.248.12.158";

// 判断是否为IP地址 (可用)
//boolean isIpAddress = Util.isIpAddress(ip);

//ip和long互转 (可用)
//long ipLong = Util.ip2long(ip);
//String strIp = Util.long2ip(ipLong);

//根据ip进行位置信息搜索
DbConfig config = new DbConfig();

//获取ip库的位置(放在src下)(直接通过测试类获取文件Ip2RegionTest为测试类)
String dbfile = Ip2RegionTest.class.getResource("/ip2region.db").getPath();

DbSearcher searcher = new DbSearcher(config, dbfile);

//采用Btree搜索
DataBlock block = searcher.btreeSearch(ip);

//打印位置信息(格式:国家|大区|省份|城市|运营商)
System.out.println(block.getRegion());
}
}

还有一种实现方法如下:

此内容参考了 ip2region源码的 : org.lionsoul.ip2region.test.TestSearcher.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package com.test;

import java.io.File;
import java.lang.reflect.Method;

import org.lionsoul.ip2region.DataBlock;
import org.lionsoul.ip2region.DbConfig;
import org.lionsoul.ip2region.DbSearcher;
import org.lionsoul.ip2region.Util;

public class IPUtil {

public static String getCityInfo(String ip){

//db
String dbPath = IPUtil.class.getResource("/ip2region.db").getPath();

File file = new File(dbPath);
if ( file.exists() == false ) {
System.out.println("Error: Invalid ip2region.db file");
}

//查询算法
int algorithm = DbSearcher.BTREE_ALGORITHM; //B-tree
//DbSearcher.BINARY_ALGORITHM //Binary
//DbSearcher.MEMORY_ALGORITYM //Memory
try {
DbConfig config = new DbConfig();
DbSearcher searcher = new DbSearcher(config, dbPath);

//define the method
Method method = null;
switch ( algorithm )
{
case DbSearcher.BTREE_ALGORITHM:
method = searcher.getClass().getMethod("btreeSearch", String.class);
break;
case DbSearcher.BINARY_ALGORITHM:
method = searcher.getClass().getMethod("binarySearch", String.class);
break;
case DbSearcher.MEMORY_ALGORITYM:
method = searcher.getClass().getMethod("memorySearch", String.class);
break;
}

DataBlock dataBlock = null;
if ( Util.isIpAddress(ip) == false ) {
System.out.println("Error: Invalid ip address");
}

dataBlock = (DataBlock) method.invoke(searcher, ip);

return dataBlock.getRegion();

} catch (Exception e) {
e.printStackTrace();
}

return null;
}


public static void main(String[] args) throws Exception{
System.err.println(getCityInfo("220.248.12.158"));
}
}

资料来源:Java 根据 IP 地址来获取 位置 – 使用 ip2region

Joda-Time——Java 日期时间处理库

pom.xml

1
2
3
4
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>

具体API查看:https://www.oschina.net/p/joda-time?hmsr=aladdin1e1

[TOC]

第一章 操作系统引论及概述


在这里插入图片描述

1.1.1、概念、功能与目标

  1. 定义:

    操作系统(Operating System,OS)是指控制和管理整个计算机系统的硬件软件资源,并合理地组织调度计算机的工作和资源的分配;以提供给用户和其他软件方便的接口和环境;它是计算机系统中最基本的系统软件

    1. 操作系统是系统资源的管理者,负责管理协调硬件、软件等计算机资源的工作
    2. 向上层的应用程序、用户提供方便易用的服务
    3. 操作系统是最接近硬件的一层软件,是系统软件不是硬件

    image-20210406102804945

  2. 功能与目标

    1. 操作系统是系统资源的管理者

      image-20210406102859419

    2. 向上层提供方便易用的服务

      • 命令接口

        • 联机命令接口实例(Windows系统) 联机命令接口=交互式命令接口

          特点:用户说一句,系统跟着做一句

        • 脱机命令接口实例(Windows系统) 脱机命令接口=批处理命令接口

          使用windows系统的搜索功能,搜索C盘中的 *.bat文件,用记事本任意打开一个。

          特点:用户说一堆,系统跟着做一堆

      • 程序接口

        可以在程序中进行系统调用来使用程序接口。普通用户不能直接使用程序接口,只能通过程序代码间接使用。

        如C盘Windows\System32中有很多的*.dll文件。程序员在程序中调用(该调用过程即为系统调用)即可实现创建窗口等功能。

        image-20210406104140771

      • GUI:图形用户界面(Graphical User Interface)

        用户可以使用形象的图形界面进行操作,而不再需要记忆复杂的命令、参数。
        例子:在Windows 操作系统中,删除一个文件只需要把文件“拖拽”到回收站即可。

      image-20210406104309877

    3. 操作系统是最接近硬件的一层软件

      封装思想:操作系统把一些丑陋的硬件功能封装成简单易用的服务,使用户能更方便地使用计算机,用户无需关心底层硬件的原理,只需要对操作系统发出命令即可

      image-20210406104343359

  3. 脑图

    image-20210406104426461

1.1.2、操作系统的四个特征

  1. 并发

    并发与并行的区别:

    • 并发:两个或多个事件在同一时间间隔内发生。这些事件宏观上是同时发生的,但微观上是交替发生的。
    • 并行:指两个或多个事件在同一时刻同时发生

    例子:

    image-20210406111514894

    操作系统的并发性指计算机系统中“同时”运行着多个程序,这些程序宏观上看是同时运行着的,而微观上看是交替运行的。

    操作系统就是伴随着“多道程序技术”而出现的。因此,操作系统和程序并发是一起诞生的

    注意:

    • 单核CPU同一时刻只能执行一个程序,各个程序只能并发地执行
    • 多核CPU同一时刻可以同时执行多个程序,多个程序可以并行地执行
  2. 共享

    共享即资源共享,是指系统中的资源可供内存中多个并发执行的进程共同使用。

    • 互斥共享方式:

      系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源

    • 同时共享方式:

      系统中的某些资源,允许一个时间段内由多个进程“同时”对它们进行访问

    所谓的“同时”往往是宏观上的,而在微观上,这些进程可能是交替地对该资源进行访问的(即分时共享

    生活实例:

    • 互斥共享方式:使用QQ和微信视频。同一时间段内摄像头只能分配给其中一个进程。
    • 同时共享方式:使用QQ发送文件A,同时使用微信发送文件B。宏观上看,两边都在同时读取并发送文件,说明两个进程都在访问硬盘资源,从中读取数据。微观上看,两个进程是交替着访问硬盘的。

    并发与共享是操作系统最基本的两个特征,两者互为存在条件

    • 并发性指计算机系统中同时存在着多个运行着的程序。
    • 共享性是指系统中的资源可供内存中多个并发执行的进程共同使用。

    image-20210406112122162

  3. 虚拟

    虚拟是指把一个物理上的实体变为若干个逻辑上的对应物。物理实体(前者)是实际存在的,而逻辑上对应物(后者)是用户感受到的。

    虚拟技术

    • 空分复用技术(如虚拟存储器技术):实际只有4GB的内存,在用户看来似乎远远大于4GB
    • 时分复用技术(如虚拟处理器):微观上处理机在各个微小的时间段内交替着为各个进程服务

    显然,如果失去了并发性,则一个时间段内系统中只需运行一道程序,那么就失去了实现虚拟性的意义了。因此,没有并发性,就谈不上虚拟性

  4. 异步

    异步是指,在多道程序环境下,允许多个程序并发执行,但由于资源有限,进程的执行不是一贯到底的,而是走走停停,以不可预知的速度向前推进,这就是进程的异步性。

    由于并发运行的程序会争抢着使用系统资源,而系统中的资源有限,因此进程的执行不是一贯到底的,而是走走停停的,以不可预知的速度向前推进。

    如果失去了并发性,即系统只能串行地运行各个程序,那么每个程序的执行会一贯到底。只有系统拥有并发性,才有可能导致异步性。

脑图:

image-20210406112516655

1.1.3、操作系统的发展与分类

操作系统的发展:

  1. 手工操作阶段

    主要缺点:用户独占全机、人机速度矛盾导致资源利用率极低

  2. 批处理阶段

    1. 单道批处理系统

      引入脱机输入/输出技术(用外围机+磁带完成),并由**监督程序(操作系统的雏形)**负责控制作业的输入、输出

      主要优点:缓解了一定程度的人机速度矛盾,资源利用率有所提升。

      主要缺点:内存中仅能有一道程序运行,只有该程序运行结束之后才能调入下一道程序。CPU有大量的时间是在空闲等待I/O完成。资源利用率依然很低。

    2. 多道批处理系统

      操作系统正式诞生,用于支持多道程序并发运行

      主要优点:多道程序并发执行,共享计算机资源。资源利用率大幅提升,CPU和其他资源更能保持“忙碌”状态,系统吞吐量增大

      主要缺点:用户响应时间长,没有人机交互功能(用户提交自己的作业之后就只能等待计算机处理完成,中间不能控制自己的作业执行。eg:无法调试程序/无法在程序运行过程中输入一些参数)

  3. 分时操作系统

    计算机以时间片为单位轮流为各个用户/作业服务,各个用户可通过终端与计算机进行交互。

    主要优点:用户请求可以被即时响应,解决了人机交互问题。允许多个用户同时使用一台计算机,并且用户对计算机的操作相互独立,感受不到别人的存在。

    主要缺点:不能优先处理一些紧急任务。操作系统对各个用户/作业都是完全公平的,循环地为每个用户/ 作业服务一个时间片,不区分任务的紧急性

  4. 实时操作系统

    在实时操作系统的控制下,计算机系统接收到外部信号后及时进行处理,并且要在严格的时限内处理完事件。实时操作系统的主要特点是及时性可靠性

    • 硬实时系统:必须在绝对严格的规定时间内完成处理。如:导弹控制系统、自动驾驶系统
    • 软实时系统:能接受偶尔违反时间规定。如:12306火车订票系统

    主要优点:能够优先响应一些紧急任务,某些紧急任务不需时间片排队。

操作系统的分类:

  1. 网络操作系统

    伴随着计算机网络的发展而诞生的,能把网络中各个计算机有机地结合起来,实现数据传送等功能,实现网络中各种资源的共享(如文件共享)和各台计算机之间的通信。(如:Windows NT 就是一种典型的网络操作系统,网站服务器就可以使用)

  2. 分布式操作系统

    主要特点是分布性并行性。系统中的各台计算机地位相同,任何工作都可以分布在这些计算机上,由它们并行、协同完成这个任务

  3. 个人计算机操作系统

    如 Windows XP、MacOS,方便个人使用

脑图:

image-20210406115156103

1.1.4、操作系统的运行机制与体系结构

  1. 运行机制

    • 两种指令

      • 特权指令:不允许用户程序使用。如内存清零指令
      • 非特权指令:如普通的运算指令
    • 两种处理器状态

      • 用户态(目态):此时CPU只能执行非特权指令
      • 核心态(管态):特权指令、非特权指令都能执行

      两种处理器状态用程序状态字寄存器(PSW)中的某标志位来标识当前处理器处于什么状态,如0表示用户态,1表示核心态。

    • 两种程序

      • 内核程序

        操作系统的内核程序是系统的管理者,既可以执行特权指令,也可以执行非特权指令,运行在核心态

      • 应用程序

        为了保证系统的安全运行,普通应用程序只能执行非特权指令,运行在用户态

    image-20210406125651838

  2. 操作系统内核

    内核是计算机上配置的底层软件,是操作系统最基本、最核心的部分

    实现操作系统内核功能的那些程序就是内核程序

    • 时钟管理:实现计时管理
    • 中断处理:负责实现中断机制
    • 原语
      • 是一种特殊的程序
      • 处于操作系统最底层,是最接近硬件的部分
      • 这种程序的运行具有原子性,其运行只能一气呵成,不可中断
      • 运行时间短,调用频繁
    • 对系统资源进行管理的功能(有的操作系统不把这部分功能归为“内核功能”。也就是说,不同的操作系统,对内核功能的划分可能并不一样)
      • 进程管理
      • 存储器管理
      • 设备管理
  3. 体系结构

    • 大内核

      将操作系统的主要功能都作为系统内核运行在核心态

      优点:高性能

      缺点:内核代码大,结构混乱,难以维护

      典型的大内核/宏内核/单内核操作系统:Linux、UNIX

    • 微内核

      只把最基本的功能保留在内核

      优点:内核功能少,结构清晰,方便维护

      缺点:需要频繁地在核心态与用户态之间切换,性能低

      典型的微内核操作系统:Windows NT

    image-20210408192745129

    类比:

    image-20210406131034245

    image-20210406130802115

脑图:

image-20210406115337381

1.1.5、中断与异常

  1. 中断机制的诞生

    在早期的计算机没有中断机制,各个程序只能串行执行,系统资源的利用率低。

    为了解决上述问题,人们发明了操作系统(作为计算机的管理者),引入中断机制,实现了多道程序并发执行。

    本质:发生中断就意味着需要操作系统介入,开展管理工作

  2. 中断的概念与作用

    • 中断发生时,CPU立即进入核心态
    • 当中断发生后,当前运行的进程暂停运行,并有操作系统内核对中断进行处理
    • 对于不同的中断信号,会进行不同的处理

    发生中断就意味着需要操作系统介入,开展管理工作。由于操作系统的管理工作(比如进程切换、分配I/O设备等)需要使用特权指令,因此CPU要从用户态转为核心态。中断可以使CPU从用户态切换为核心态,使操作系统获得计算机的控制权。有了中断,才能实现多道程序并发执行。

    中断是实现CPU从用户态切换到核心态的唯一途径。通过执行一个特权指令,将程序状态字(PSW)对标志位设置为“核心态”。

  3. 中断(广义的中断)的分类

    • 内中断(也称“异常、例外、陷入”):与当前执行的指令有关,中断信号来源于CPU内部
      • 自愿中断:指令中断(如:系统调用时使用的访管指令(又叫陷入指令、trap指令))
      • 强迫中断
        • 硬件故障(如:缺页)
        • 软件故障(如:整数除0)
    • 外中断(也称“中断(狭义的中断)”):与当前执行的指令无关,中断信号来源于CPU外部
      • 外设请求(如:I/O操作完成发出的中断信号)
      • 人工干预(如:用户强行终止一个进程)

    image-20210406134755249

    另一种分类方式:

    • 内中断(也称“异常、例外、陷入”):与当前执行的指令有关,中断信号来源于CPU内部
      • 陷阱、陷入(trap):有意而为之的异常,如系统调用
      • 故障(fault):由错误条件引起的,可能被内核程序修复。内核程序修复故障后会把CPU使用权还给应用程序,让它继续执行下去。如:缺页故障。
      • 终止(abort):由致命错误引起,内核程序无法修复该错误,因此一般不再将CPU使用权还给引发终止的应用程序,而是直接终止该应用程序。如:整数除0、非法使用特权指令。
    • 外中断(也称“中断(狭义的中断)”):与当前执行的指令无关,中断信号来源于CPU外部
      • 外设请求(如:I/O操作完成发出的中断信号)
      • 人工干预(如:用户强行终止一个进程)

    image-20210406135217118

  4. 外中断的处理过程

    1. 检查:执行完每个指令之后,CPU都要检查当前是否有外部中断信号
    2. 保护:如果检测到外部中断信号,则需要保护被中断进程的CPU环境(如程序状态字PSW、程序计数器PC、各种通用寄存器)
    3. 处理:根据中断信号类型转入相应的中断处理程序
    4. 恢复:恢复原进程的CPU环境并退出中断,返回原进程继续往下执行
  5. 中断机制的基本原理

    不同的中断信号,需要用不同的中断处理程序来处理。当CPU检测到中断信号后,会根据中断信号的类型去查询“中断向量表”,以此来找到相应的中断处理程序在内存中的存放位置。

    显然,中断处理程序一定是内核程序,需要运行在“内核态”

    image-20210406135456448

脑图:

image-20210406135343449

1.1.6、系统调用

  1. 什么是系统调用

    操作系统作为用户和计算机硬件之间的接口,需要向上提供一些简单易用的服务。主要包括命令接口程序接口。其中,程序接口由一组系统调用组成。

    image-20210408185305701

    “系统调用”是操作系统提供给应用程序(程序员/编程人员)使用的接口,可以理解为一种可供应用程序调用的特殊函数,应用程序可以通过系统调用来请求获得操作系统内核的服务。

  2. 系统调用与库函数调用的区别

    image-20210408190637741

  3. 为什么系统调用是必须的

    生活场景:去学校打印店打印论文,你按下了WPS 的“打印”选项,打印机开始工作。
    你的论文打印到一半时,另一位同学按下了Word 的“打印”按钮,开始打印他自己的论文。

    思考:如果两个进程可以随意地、并发地共享打印机资源,会发生什么情况?

    两个进程并发运行,打印机设备交替地收到WPS 和Word 两个进程发来的打印请求,结果两篇论文的内容混杂在一起了…

    解决方法:由操作系统内核对共享资源进行统一的管理,并向上提供 “系统调用”,用户进程想要使用打印机这种共享资源,只能通过系统调用向操作系统内核发出请求。内核会对各个请求进行协调处理

  4. 什么功能要用系统调用实现

    应用程序通过系统调用请求操作系统的服务。而系统中的各种共享资源都由操作系统内核统一掌管,因此凡是与共享资源有关的操作(如存储分配、I/O操作、文件管理等),都必须通过系统调用的方式向操作系统内核提出服务请求,由操作系统内核代为完成。这样可以保证系统的稳定性和安全性,防止用户进行非法操作。

    image-20210408190445658

  5. 系统调用的过程

    image-20210408191025687

    1. 传递系统调用参数
    2. 执行陷入指令(用户态)
    3. 执行相应的内请求核程序处理系统调用(核心态)
    4. 返回应用程序

脑图:

image-20210408191123343

第一章总结

image-20210408192546560

第二章 进程与线程


在这里插入图片描述

在这里插入图片描述

2.1.1、进程的概念、组成与特征

1、定义——在计算机发展史上,”进程”是为了解决什么问题而被引入的?

1、进程的发展

在早期的计算机中,只支持单道程序。

image-20210408193824213

在引入多道程序技术之后(操作系统)

image-20210408193938895

进程与程序的区别:

  • 程序:是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。
  • 进程(Process):是动态的,是程序的一次执行过程

同一个程序多次执行会对应多个进程。

2、进程的定义

**程序段、数据段、PCB三部分组成了进程实体(进程映像)**。一般情况下,我们把进程实体就简称为进程,例如:所谓创建进程,实质上是创建进程实体中的PCB;而撤销进程,实质上是撤销进程实体中的PCB。

注意:PCB是进程存在的唯一标志!

从不同的角度,进程有不同的定义,比较传统典型的定义有:(强调“动态性”)进程的正在进行

  1. 进程是程序的一次执行过程
  2. 进程是一个程序及其数据在处理机上顺序执行时所发生的活动
  3. 进程是具有独立功能的程序在数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位

引入进程实体的概念后,可把进程定义为:

进程是进程实体的运行过程,是系统进行资源分配调度的一个独立单位。

注:严格来说,进程实体和进程并不一样,进程实体是静态的进程则是动态的。不过,除非题目专门考察二者区别,否则可以认为进程实体就是进程。因此我们也可以说“进程由程序段、数据段、PCB三部分组成

2、组成——每个进程由哪些部分组成

  1. PCB(Process Control Block):操作系统使用的。进程的管理者(操作系统)所需的数据都在PCB当中

    image-20210408195302165

    image-20210408195101713

    image-20210408195206139

  2. 程序段:进程自己使用的。程序本身的运行所需的数据

    存放要执行的代码

  3. 数据段:进程自己使用的。程序本身的运行所需的数据

    存放程序运行过程中处理的各种数据

image-20210408195916760

image-20210408194909291

3、组织方式——系统中的各个进程之间是如何被组织起来的

在一个系统中,通常有数十数百乃至数千个PCB。为了能对他们加以有效的管理,应该用适当的方式把这些PCB组织起来。

注意:进程的组成讨论的是一个进程内部的由哪些部分构成的问题,而进程的组织讨论的是多个进程之间的组织方式的问题。

  • 链接方式

    按照进程状态将PCB分为多个队列

    操作系统持有指向各个队列的指针

    image-20210408200538083

  • 索引方式

    根据进程状态的不同,建立几张索引表

    操作系统持有指向各个索引表的指针

    image-20210408200615846

4、特征——相比于程序,进程有什么特征

  • 动态性:进程是程序的一次执行过程,是动态地产生、变化和消亡的
  • 并发性:内存中有多个进程实体,各进程可以并发执行
  • 独立性:进程是能独立运行、独立获得资源、独立接收调度的基本单位
  • 异步性:各进程按各自独立的,不可预知的速度向前推进,操作系统要提供”进程同步机制“来解决异步问题
  • 结构性:每个进程都会配置一个PCB。结构上看,进程由程序段、数据段、PCB组成

image-20210408200659340

脑图

image-20210408200752313

2.1.2、进程的状态与转换

1、进程的状态

进程是程序的一次执行,在这个执行过程中,有时进程正在被CPU处理,有时又需要等待CPU服务,可见,进程的状态是会有各种变化。为了方便对各种进程的管理,操作系统需要将进程合理地划分为几种状态。

进程有五种状态,其中有三种基本状态

三种基本状态:

  • 运行态(Running):占有CPU,并在CPU上运行

    注意:在单核处理机环境下,每一时刻最多只有一个进程处于运行态。(双核环境下可以同时有两个进程处于运行态)

  • 就绪态(Ready):已经具备运行条件,但由于没有空闲的CPU,而暂时不能运行

    进程已经拥有了除处理机之外所有需要的资源,一旦获得处理机,即可立即进入运行态开始运行。即:万事俱备,只欠CPU

  • 阻塞态(Waiting/Blocked,又称:等待态):因等待某一事件而暂时不能运行

    如:等待操作系统分配打印机、等待读磁盘操作的结果,CPU是计算机中最昂贵的部件,为了提高CPU的利用率,需要先将其他进程需要的资源分配到位,才能得到CPU的服务。

剩余的两种状态:

  • 创建状态(New,又称:新建态):进程正在被创建,操作系统为进程分配资源、初始化PCB

    操作系统需要完成创建进程。操作系统为该进程分配所需的内存空间等系统资源,并为其创建、初始化PCB(如:为进程分配PID)

  • 终止状态(Terminated,又称:结束态):进程正在从系统中撤销,操作系统会回收进程拥有的资源、撤销PCB

    进程运行结束(或者由于bug导致进程无法继续执行下去,比如数组越界错误,除数为0等等),需要撤销进程。

    操作系统需要完成撤销进程的相关的工作。完成将分配给进程的资源回收,撤销进程PCB等工作。

2、进程状态间的转换

image-20210408210631200

  • 就绪态 => 运行态
  • 运行态 => 就绪态
  • 运行态 => 阻塞态
  • 阻塞态 => 就绪态

注意:不能有阻塞态直接转换为运行态,也不能由就绪态直接转换为阻塞态(因为进入阻塞态是进程主动请求的,必然需要进程在运行时才能发出这种请求)

脑图

image-20210408210724237

2.1.3、进程控制

1、基本概念

1、什么是进程控制

进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程撤销已有进程实现进程状态转换等功能。

简化理解:反正进程控制就是要实现进程状态转换

image-20210408223038360

2、如何实现进程控制——原语

原语是一种特殊的程序,它的执行具有原子性。也就是说,这段程序的运行必须一气呵成,不可中断

如果不能“一气呵成”,就有可能导致操作系统中的某些关键数据结构信息不统一的情况,这会影响操作系统进行别的管理工作。

原语的执行具有原子性,即执行过程只能一气呵成,期间不允许被中断。可以用“关中断指令”和“开中断指令”这两个特权指令实现原子性

正常情况:CPU每执行完一条指令都会例行检查是否有中断信号需要处理,如果有,则暂停运行当前这段程序,转而执行相应的中断处理程序。

CPU执行了关中断指令之后,就不再例行检查中断信号,直到执行开中断指令之后才会恢复检查。

这样,关中断、开中断之间的这些指令序列就是不可被中断的,这就实现了“原子性”。

image-20210408223258148

2、进程控制相关的原语

学习技巧:进程控制会导致进程状态的转换。无论哪个原语,要做的无非三类事情:

  1. 更新PCB中的信息(如修改进程状态标准、简化运行环境保存到PCB、从PCB恢复运行环境)
    1. 所有的进程控制原语一定都会修改进程状态标准
    2. 剥夺当前运行进程的CPU使用权必然需要保存其运行环境
    3. 某进程开始运行前必然要恢复其运行环境
  2. 将PCB插入合适的队列
  3. 分配/回收资源
  • 进程的创建

    image-20210408223606502

  • 进程的终止

    image-20210408223742379

  • 进程的阻塞与唤醒

    image-20210408224532652

  • 进程的切换

    image-20210408224604511

脑图

image-20210408223947869

2.1.4、进程通信

什么是进程通信?

顾名思义,进程通信就是指进程之间的信息交换

进程是分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。为了保证安全,一个进程不能直接访问另一个进程的地址空间。但是进程之间的信息交换又是必须实现的。为了保证进程间的安全通信,操作系统提供了一些方法。如下:

1、共享存储

两个进程对共享空间的访问必须是互斥的(互斥访问通过操作系统提供的工具实现)。
操作系统只负责提供共享空间同步互斥工具(如P、V操作)

  • 基于数据结构的共享:

    比如共享空间里只能放一个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式

  • 基于存储区的共享:

    在内存中画出一块共享存储区,数据的形式、存放位置都由进程控制,而不是操作系统。相比之下,这种共享方式速度更快,是一种高级通信方式。

image-20210409014400967

2、消息传递

进程间的数据交换以格式化的消息(Message)为单位。进程通过操作系统提供的“发送消息/接收消息”两个原语进行数据交换。

格式化的信息包含消息头和消息体。在消息头中包括:发送进程ID、接受进程ID、消息类型、消息长度等格式化的信息(计算机网络中发送的“报文”其实就是一种格式化的消息)

  • 直接通信方式:消息直接挂到接收进程的消息缓冲队列上

    image-20210409015008639

  • 间接通信方式:消息要先发送到中间实体(信箱)中,因此也称“信箱通信方式”。Eg:计网中的电子邮件系统。

    image-20210409015029868

3、管道通信

“管道”是指用于连接读写进程的一个共享文件,又名pipe文件。其实就是在内存中开辟一个大小固定的缓冲区

image-20210409014459852

  1. 管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信,则需要设置两个管道
  2. 各进程要互斥地访问管道。
  3. 数据以字符流的形式写入管道,当管道写满时,写进程的write()系统调用将被阻塞,等待读进程将数据取走。当读进程将数据全部取走后,管道变空,此时读进程的read()系统调用将被阻塞
  4. 如果没写满,就不允许读。如果没读空,就不允许写。
  5. 数据一旦被读出,就从管道中被抛弃,这就意味着读进程最多只能有一个,否则可能会有读错数据的情况。

脑图

image-20210409015059025

2.1.5、线程的概念、特点与多线程模型

1、什么是线程?为什么要引入线程?

进程是程序的一次执行。同一进程里不同的功能显然需要用不同的几段程序才能实现,并且这几段程序还要并发运行(qq当中的视频、文字聊天、传送文件)。而且,当切换进程时,需要保存/恢复进程运行环境,还需要切换内存地址空间(更新快表、更新缓存)开销很大。

image-20210409021721725

有的进程可能需要“同时”做很多事,而传统的进程只能串行地执行一系列程序。为此,引入了“线程”,来增加并发度。

image-20210409021850582

2、与进程相比,线程有什么特点?

  • 可以把线程理解为“轻量级进程”。

  • 引入线程前,进程既是资源分配的基本单位,也是调度的基本单位

  • 引入线程后,进程是资源分配的基本单位线程是调度的基本单位线程也有运行态、就绪态、阻塞态

  • 多CPU环境下,各个线程也可以分派到不同的CPU上并行地执行。

  • 线程是一个基本的CPU执行单元,也是程序执行流的最小单位

  • 引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的)。线程则作为处理机的分配单元

  • 引入线程后,进程是资源分配的基本单位。而线程几乎不拥有资源,只拥有极少量的资源(线程控制块TCB(Thread Control Block)、寄存器信息、堆栈等

  • 引入线程之后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)

  • 进程间并发,开销很大。线程间并发,开销更小。进程间通信必须请求操作系统服务(CPU要切换到核心态),开销大。同进程下的线程间通信,无需操作系统干预,开销更小。引入线程机制后,并发带来的系统开销降低,系统并发性提升。

    注意:从属于不同进程的线程间切换,也必须请求操作系统服务!也会导致进程的切换!开销也大

    当切换进程时,需要保存/恢复进程运行环境,还需要切换内存地址空间(更新快表、更新缓存)

    同一进程内的各个线程间并发,不需要切换进程运行环境和内存地址空间,省时省力。

  • 从属同一进程的各个线程共享进程拥有的资源

  • 各个进程的内存地址空间相互独立,只能通过请求操作系统内核的帮助来完成进程间通信。

  • 同一进程下的各个线程间共享内存地址空间,可以直接通过读/写内存空间进行通信。

总结:

线程最小执行单位,进程最小分配资源单位

进程是可拥有资源的基本单位,频繁创建撤销进程会造成很大时空开销;而线程只是独立调度和分派的基本单位,共享进程的系统资源,线程被频繁创建和撤销也不会造成太大的时空开销。那仍然是执行的一个进程,只不过同时执行一个进程里面的多个线程。有些程序语言里还有更更轻量的协程,都是为了降低并发的代价。

3、引入线程机制后,有什么变化?

image-20210409023101820

类比:

切换进程运行环境:有一个不认识的人要用桌子,你需要你的书收走,他把自己的书放到桌上
同一进程内的线程切换=你的舍友要用这张书桌,可以不把桌子上的书收走。

4、线程有哪些重要的属性?

image-20210409023226229

5、线程的实现方式

  • 用户级线程(User-Level Thread, ULT)

    用户级线程由应用程序通过线程库实现。

    所有的线程管理工作都由应用程序负责(包括线程切换)

    用户级线程中,线程切换可以在用户态下即可完成,无需操作系统干预。

    在用户看来,是有多个线程。但是在操作系统内核看来,并意识不到线程的存在。(用户级线程对用户不透明,对操作系统透明

    可以这样理解,“用户级线程”就是“从用户视角看能看到的线程”

    image-20210409024313592

  • 内核级线程(Kernel-Level Thread, KLT, 又称“内核支持的线程”)

    内核级线程的管理工作操作系统内核完成

    线程调度、切换等工作都由内核负责,因此内核级线程的切换必然需要在核心态下才能完成。

    可以这样理解,“内核级线程”就是“从操作系统内核视角看能看到的线程”

    image-20210409024248522

线程的实现方式:

在同时支持用户级线程和内核级线程的系统中,可采用二者组合的方式:将n个用户级线程映射到m个内核级线程上(n >= m)

重点重点重点: 操作系统只“看得见”内核级线程,因此只有内核级线程才是处理机分配的单位。

例如:下边这个模型中,该进程由两个内核级线程,三个用户级线程,在用户看来,这个进程中有三个线程。但即使该进程在一个4核处理机的计算机上运行,也最多只能被分配到两个核,最多只能有两个用户线程并行执行。

image-20210409024124625

6、多线程模型

在同时支持用户级线程和内核级线程的系统中,由几个用户级线程映射到几个内核级线程的问题引出了“多线程模型”问题。

  • 多对一模型:

    多个用户及线程映射到一个内核级线程。每个用户进程只对应一个内核级线程。

    优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。

    缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。

    image-20210409024313592

  • 一对一模型:

    一个用户及线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。

    优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。

    缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。

    image-20210409024248522

  • 多对多模型

    n 用户及线程映射到m 个内核级线程(n >= m)。每个用户进程对应m 个内核级线程。

    克服了多对一模型并发度不高的缺点,又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。

    image-20210409024124625

脑图

image-20210409024100247

2.2.1、处理机调度的概念、层次

1、基本概念

当有一堆任务要处理,但由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定(如:VIP优先、短作业优先调等等)处理这些任务的顺序,这就是“调度”研究的问题。
在多道程序系统中,进程的数量往往是多于处理机的个数的,这样不可能同时并行地处理各个进程。处理机调度,就是从就绪队列中按照一定的算法选择一个进程并将处理机分配给它运行,以实现进程的并发执行。

2、三个层次

1、高级调度(作业调度)

image-20210504210138470

2、中级调度(内存调度)

image-20210504210203139

3、低级调度(进程调度)

image-20210504210230216

3、三层调度的联系、对比

image-20210504210333146

4、补充知识——进程的”挂起态”与七状态模型

image-20210504210257856

脑图

image-20210504210409703

2.2.2、进程调度的时机、切换与过程、方式

1、时机

1、什么时候需要进程调度

image-20210504211825916

2、什么时候不需要进程调度(但是进程在普通临界区中是可以进行调度、切换的。)

image-20210504211925883

3、临界区与内核程序临界区

image-20210504212056981

image-20210504212117707

如果还没退出临界区(还没解锁)就进行进程调度,但是进程调度相关的程序也需要访问就绪队列,但此时就绪队列被锁住了,因此又无法顺利进行进程调度。

内核程序临界区访问的临界资源如果不尽快释放的话,极有可能影响到操作系统内核的其他管理工作。因此在访问内核程序临界区期间不能进行调度与切换

image-20210504212504714

在打印机打印完成之前,进程一直处于临界区内,临界资源不会解锁。但打印机又是慢速设备,此时如果一直不允许进程调度的话就会导致CPU一直空闲。

普通临界区访问的临界资源不会直接影响操作系统内核的管理工作。因此在访问普通临界区时可以进行调度与切换

2、切换与过程

1、”狭义的调度”与”进程切换”的区别

image-20210504212743872

3、方式

1、非剥夺调度方式(非抢占式)

image-20210504212658968

2、剥夺调度方式(抢占式)

image-20210504212718957

脑图

image-20210504212903088

2.2.3、调度算法的评价指标

1、CPU利用率

image-20210504213732211

2、系统吞吐量

image-20210504213750111

3、周转时间

1、周转时间、平均周转时间

image-20210504213811576

2、带权周转时间、平均带权周转时间

image-20210504213904684

即:周转时间都是11s,但是作业1的运行时间是1s,等待时间是10s;而作业2的运行时间是10s,等待时间是1s。

image-20210504213926076

4、等待时间

image-20210504214350127

5、响应时间

image-20210504214421607

脑图

image-20210504214513619

2.2.4、调度算法:先来先服务、最短作业优先、最高响应比优先

Tips:各种调度算法的学习思路

  1. 算法思想
  2. 算法规则
  3. 这种调度算法是用于作业调度还是进程调度?
  4. 抢占式?非抢占式?
  5. 优点和缺点
  6. 是否会导致饥饿:
    • 某进程/作业长期得不到服务

1、先来先服务(First Come First Serve:FCFS)

image-20210504220945017

相关例题:

image-20210504221017275

2、短作业优先( Shortest Job First:SJF)

image-20210504221101352

相关例题

1、非抢占式的短作业优先

image-20210504221228030

2、抢占式的短作业优先

image-20210504221334542

image-20210504221401081

细节:

image-20210504221427980

3、对两种算法的思考

image-20210504221522717

4、高响应比优先(Highest Response Ratio Next:HRRN)

image-20210504221551173

相关例题:

image-20210504221620857

5、知识回顾与重要考点

image-20210504214906931

2.2.5、调度算法:时间片轮转、优先级、多级反馈队列

Tips:各种调度算法的学习思路

  1. 算法思想
  2. 算法规则
  3. 这种调度算法是用于作业调度还是进程调度?
  4. 抢占式?非抢占式?
  5. 优点和缺点
  6. 是否会导致饥饿:
    • 某进程/作业长期得不到服务

1、时间片轮转调度算法

image-20210504223036879

相关例题:

时间片为2

image-20210504223228420

image-20210504223311894

image-20210504223733579

时间片为5

image-20210504223829429

时间片的选取&时间片轮转调度算法与先来先服务算法的关系:

image-20210504224009814

image-20210504224059700

2、优先级调度算法

image-20210504224852371

相关例题:

非抢占式的优先级调度算法:

image-20210504224320500

抢占式的优先级调度算法:

image-20210504224457516

补充:

image-20210504224802316

3、思考

image-20210504224934560

4、多级反馈队列调度算法

image-20210504225527480

相关例题

image-20210504225342558

5、知识回顾与重要考点

image-20210504225811770

2.3.1、进程同步、进程互斥

1、进程同步

image-20210504230549925

2、进程互斥

image-20210504230709464

对临界资源的互斥访问,可以在逻辑上分为如下四个部分:

image-20210504230942497

为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:

image-20210504231106627

脑图

image-20210504231143008

2.3.2、进程互斥的软件实现方法

学习提示:

  1. 理解各个算法的思想、原理
  2. 结合上小节学习的“实现互斥的四个逻辑部分”,重点理解各算法在进入区、退出区都做了什么
  3. 分析各算法存在的缺陷(结合“实现互斥要遵循的四个原则”进行分析)

1、单标志法

image-20210504231820629

image-20210504231746318

2、双标志先检查

image-20210504232145546

3、双标志后检查

image-20210504232413700

4、Peterson算法

image-20210504232720257

image-20210504232756435

image-20210504232829595

image-20210504232858921

脑图

image-20210504232928285

2.3.3、进程互斥的硬件实现方法

学习提示:

  1. 理解各方法的原理
  2. 了解各方法的优缺点

1、中断屏蔽方法

image-20210504233615762

2、TestAndSet(TS指令/TSL指令TestAndSetLock)

image-20210504233641846

3、Swap指令(XCHG指令)

image-20210504233700709

脑图

image-20210504233729313

2.3.4、信号量机制

复习回顾+思考:之前学习的这些进程互斥的解决方案分别存在哪些问题?

  • 进程互斥的四种软件实现方式(单标志法、双标志先检查、双标志后检查、Peterson算法 )
  • 进程互斥的三种硬件实现方式(中断屏蔽方法、TS/TSL指 令、Swap/XCHG指令)
    • 在双标志先检查法中,进入区的“检查”、“ 上锁”操作无法一气呵成,从而导致了两个进程有可能同时进入临界区的问题;
    • 所有的解决方案都无法实现“让权等待”

1965年,荷兰学者Dijkstra提出了一种卓有成效的实现进程互斥、同步的方法——信号量机制

1、信号量机制

image-20210504234407772

2、整型信号量

image-20210504235542982

3、纪录型信号量

image-20210504235617935

image-20210505000153209

image-20210505000232848

image-20210505000249924

脑图

image-20210505000323832

2.3.5、用信号量实现进程互斥、同步、前驱关系

Tips:不要一头钻到代码里,要注意理解信号量背后的含义,一个信号量对应一种资源

信号量的值 = 这种资源的剩余数量(信号量的值如果小于0,说明此时有进程在等待这种资源)

P(S)——申请一个资源S,如果资源不够就阻塞等待
V(S)——释放一个资源S,如果有进程在等待该资源,则唤醒一个进程

1、实现进程互斥

image-20210505001722795

2、实现进程同步

image-20210505001748039

image-20210505001810855

3、实现进程的前驱关系

image-20210505001831897

脑图

image-20210505001903980

2.3.6、生产者-消费者问题

1、问题描述

image-20210505004917915

2、问题分析

image-20210505003435388

image-20210505004125366

image-20210505004950707

3、问题解决

image-20210505005740558

4、思考:能否改变相邻P、V操作的顺序?

image-20210505005811868

“使用产品”能不能放在”取出产品之后”(即PV操作之间):

从逻辑上来说没什么问题,取出一个产品之后马上使用。但是最好不要。因为这会导致临界区的代码量变大,消费者进程在访问临界区资源的时候就会耗费更长的时间,如果此时有别的进程也想访问临界区资源的话是会被阻塞的。

把这些非必要的代码放进临界区的话,就显然会导致进程间的并发度降低,所以最好不要把没有必要的代码放到临界区里面。

5、知识回顾与重要考点

image-20210505004859079

2.3.7、生产者-多消费者

1、问题描述

image-20210505011453970

2、问题分析

image-20210505011627140

image-20210505011725280

3、问题解决

方式一:使用互斥信号量mutex

image-20210505012011434

方式二:不使用互斥信号量mutex

image-20210505012224581

为什么只可以使用(同步)信号量plate,省略(异步)信号量mutex解决问题呢?

原因在于:本题中的缓冲区大小为1,在任何时刻,apple、 orange、 plate三个同步信号量中最多只有一个是1。因此在任何时刻,最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区。

如果把缓冲区的大小设置为2,即盘子里面可以放置两个水果

image-20210505012546283

4、知识回顾与重要考点

image-20210505011103373

image-20210505011126965

2.3.8、吸烟者问题

1、问题描述

image-20210505013010023

2、问题分析

image-20210505013811237

image-20210505013903213

3、问题解决

image-20210505013926731

4、知识回顾与重要考点

image-20210505012943618

2.3.9、读者-写者问题

1、问题描述

image-20210505014140357

2、问题分析

image-20210505014119129

3、问题解决

image-20210505014207251

image-20210505014235757

4、知识回顾与重要考点

image-20210505014049722

2.3.10、哲学家进餐问题

1、问题描述

image-20210505015934159

2、问题分析

image-20210505020031496

3、问题解决

image-20210505020244129

image-20210505020339544

第一种情况:0号进程拿起左右两支筷子进行吃饭(顺利进行)

image-20210505020554497

第二种情况:在第一种情况的基础下,0号进程正在吃饭,此时1号进程想要吃饭,但是会被阻塞在拿左边筷子的语句P(chopstick[i])上,此时2号进程想要吃饭,但是由于1号进程执行了P(mutex)但是没执行V(mutex),所以2号进程会被阻塞在语句P(mutex)上,即:2号进程虽然左右两边都有筷子,但是它吃不了饭。

image-20210505021347456

第三种情况:在第一种的情况下,4号进程想吃饭,它会拿起左边的筷子,然后就被阻塞在拿右边筷子的语句P(chopstick[(i+1)%5])上了

image-20210505021537145

4、知识回顾与重要考点

image-20210505021821006

2.3.11、管程

1、为什么要引入管程

image-20210505022123552

2、管程的定义和基本特征

image-20210505022141694

3、拓展1:用管程解决生产者消费者问题

image-20210505022231170

image-20210505022251625

4、拓展2:java中类似于管程的机制

image-20210505022319316

脑图

image-20210505022210103

2.4.1、死锁的概念

1、什么是死锁

image-20210505143529683

2、进程死锁、饥饿、死循环的区别

image-20210505143807098

3、死锁产生的必要条件

image-20210505144019761

4、什么时候会发生死锁

image-20210505144120239

5、死锁的处理策略

image-20210505144149160

脑图

image-20210505144218705

2.4.2、死锁的处理策略——预防死锁

1、死锁的处理

image-20210505144445903

2、破坏互斥条件

image-20210505144725993

3、破坏不剥夺条件

image-20210505145005522

4、破坏请求和保持条件

image-20210505145224511

5、破坏循环等待条件

image-20210505145453127

脑图

image-20210505145752465

2.4.3、死锁的处理策略——避免死锁

1、动态策略:避免死锁

image-20210505145945572

2、什么是安全序列

image-20210505150922419

image-20210505151042960

image-20210505151124895

image-20210505151244599

3、安全序列、不安全状态、死锁的联系

image-20210505151529696

4、银行家算法

image-20210505151659990

image-20210505151838002

image-20210505151927039

不安全的情况:

image-20210505152108400

代码实现:

image-20210505152424001

5、知识回顾与重要考点

image-20210505152609859

2.4.4、死锁的处理——策略检测和解除

1、死锁的检测和解除

image-20210505152730364

2、死锁的检测

image-20210505152953706

没有发生死锁:

image-20210505153349861

发生死锁:

image-20210505153629990

死锁定理:

image-20210505153748812

3、死锁的解除

image-20210505154108164

脑图

image-20210505154238217

第三章 内存管理

img

在这里插入图片描述

3.1.1、内存的基础知识

1、什么是内存?内存的作用——存储单元与内存地址

image-20210508002901851

2、进程运行的基本原理

1、指令的工作原理

image-20210508003140541

image-20210508003207444

image-20210508003230122

2、逻辑地址 VS 物理地址

image-20210508004518010

3、如何实现地址转换

image-20210508004749695

image-20210508004832543

4、从写程序到程序运行的过程:编辑——编译——链接——装入

image-20210508004555654

5、三种装入方式
1、三种装入方式——绝对装入

image-20210508005036546

2、三种装入方式——可重定位装入

image-20210508005056249

3、三种装入方式——动态运行时装入

image-20210508005114414

image-20210508005143082

6、三种链接方式
1、三种链接方式——静态链接

image-20210508005324193

2、三种链接方式——装入时动态链接

image-20210508005341688

3、三种链接方式——运行时动态链接

image-20210508005409340

3、补充知识:几个常用的数量单位

image-20210508002939003

脑图

image-20210508005509266

3.1.2、内存管理的概念

1、内存空间的分配与回收

image-20210508010417701

2、内存空间的扩充

image-20210508010447793

3、地址转换

image-20210508010631873

4、存储保护

image-20210508010712814

1、存储保护——方式1

image-20210508010856791

2、存储保护——方式2

image-20210508011111324

脑图

image-20210508005842818

3.1.3、覆盖与交换

1、内存空间的扩充

image-20210508083942705

1、覆盖技术

image-20210508012819104

image-20210508012843411

2、交换技术

image-20210508012931347

image-20210508012947320

image-20210508013003422

脑图

image-20210508011536720

3.1.4、内存空间的分配与回收——连续分配管理方式

image-20210508084011062

3.1.4.1.1、连续分配管理方式——单一连续分配

image-20210508090339927

3.1.4.1.2、连续分配管理方式——固定分区分配

image-20210508090358501

image-20210508090412868

3.1.4.1.3、连续分配管理方式——动态分区分配

image-20210508090450612

1、动态分区分配问题1——系统要用什么样的数据结构记录内存的使用情况?

image-20210508090546098

2、动态分区分配问题2——当很多个空闲分区都能满足需求时,应该选择哪个分区进行分配?

image-20210508090650871

3、动态分区分配问题3——如何进行分区的分配与回收操作?
1、分配——分配到相对大的空间中

image-20210508090911675

2、分配——分配到刚刚好的空间中

image-20210508091146057

3、回收——回收区的后面有一个相邻的空闲分区

image-20210508091905607

4、回收——回收区的前面有一个相邻的空闲分区

image-20210508091821239

5、回收——回收区的前、后面各有一个相邻的空闲分区

image-20210508092028487

6、回收——回收区的前、后都没有相邻的空闲分区

image-20210508092245786

7、内部碎片与外部碎片

image-20210508093103201

image-20210508093318950

脑图

image-20210508093401065

3.1.5、动态分区分配算法

image-20210508093523120

1、首次适应算法(First Fit)

image-20210508094125510

2、最佳适应算法(Best Fit)

image-20210508095209932

image-20210508095244859

3、最坏适应算法(Worst Fit)

image-20210508095837523

image-20210508095921779

4、邻近适应算法(Next Fit)

image-20210508100315188

image-20210508102952416

image-20210508103130003

5、知识回顾与重要考点

image-20210508100431925

3.1.4、内存空间的分配与回收——非连续分配管理方式

image-20210508105913844

image-20210508103253793

3.1.4.2.1、非连续分配管理方式——基本分页存储管理方式

把“固定分区分配”改造为“非连续分配版本”

image-20210508110554274

1、基本分页存储管理的基本概念

image-20210508111128672

2、如何实现地址的转换

进程在内存中连续存放时:

image-20210508111333219

主要思想:模块在内存中的的“起始地址”+ 目标内存单元相对于起始位置的“偏移量”

进程在内存中不连续存放时(采用分页存储):

image-20210508111929405

image-20210508112229037

image-20210508112510315

image-20210508112743184

结论:如果每个页面大小为2KB,用二进制数表示逻辑地址,则末尾K位即为页内偏移量,其余部分就是页号。
因此,如果让每个页面的大小为2的整数幂,计算机就可以很方便地得出一个逻辑地址对应的页号和页内偏移量。

3、逻辑地址结构

image-20210508113103944

4、页表

image-20210508113253320

image-20210508113414039

脑图

image-20210508113608275

3.1.6、基本地址变换机构

基本地址变换机构:用于实现逻辑地址到物理地址转换的一组硬件机构

image-20210508133337702

image-20210508133401880

image-20210508132845186

image-20210508132931955

image-20210508133601876

image-20210508133911049

脑图

image-20210508134019463

3.1.7、具有快表的地址变换机构

1、局部性原理

image-20210508202404524

2、快表(TLB)

image-20210508225440701

image-20210508225501750

3、引入快表后,地址的变换过程

image-20210508225813674

4、知识回顾与重要考点

image-20210508225906098

3.1.8、两级页表

1、单级页表存在什么问题?如何解决?

image-20210508234002478

image-20210508235354588

image-20210508235432906

2、两级页表的原理、逻辑地址结构

image-20210508235458794

image-20210508235527071

3、如何实现地址变换?

image-20210509002703906

4、如何解决单级页表的问题?

image-20210509002832170

5、两级页表问题需要注意的几个细节

image-20210509003331829

脑图

image-20210509003459726

3.1.4.2.2、非连续分配管理方式——基本分段存储管理方式

1、分段

image-20210509004013708

image-20210509004209165

2、段表

image-20210509004817652

3、地址变换

image-20210509004846961

image-20210509004926128

4、分段、分页管理的对比

image-20210509005230009

image-20210509005348731

image-20210509005415434

image-20210509005436167

脑图

image-20210509005500533

3.1.4.2.3、非连续分配管理方式——段页式管理方式

1、分页、分段的优缺点分析

image-20210509005829321

image-20210509005855867

2、分段+分页=段页式管理

image-20210509005945705

3、段页式管理的逻辑地址结构

image-20210509010126138

4、段表、页表

image-20210509010510498

image-20210509010923423

脑图

image-20210509011052374

3.2.1、虚拟内存的基本概念

image-20210509011129396

1、传统存储管理方式的特征、缺点

image-20210509011637911

2、局部性原理

image-20210509011701550

3、虚拟内存的定义和特征

image-20210509011935455

image-20210509012047548

4、如何实现虚拟内存技术

image-20210509012152298

脑图

image-20210509012242692

3.2.2、请求分页管理方式

image-20210509012615162

1、页表机制

image-20210509012717541

2、缺页中断机构

内存中存在空闲块:

image-20210509013338043

image-20210509012743561

内存中不存在空闲块:

image-20210509013627907

image-20210509012801635

image-20210509012828125

3、地址变换机构

image-20210509012911260

image-20210509013853533

image-20210509013943280

image-20210509014238955

image-20210509014011536

脑图

image-20210509012956979

3.2.3、页面置换算法

image-20210509014356859

1、页面置换算法——最佳置换算法(OPT)

image-20210509014635661

image-20210509014650392

2、页面置换算法——先进先出置换算法(FIFO)

image-20210509014718214

image-20210509014853923

3、页面置换算法——最近最久未使用置换算法(LRU)

image-20210509014927703

4、页面置换算法——时钟置换算法(CLOCK)

image-20210509015030833

5、页面置换算法——改进型的时钟置换算法

image-20210509021015176

image-20210509021320133

image-20210509021715893

image-20210509022026524

image-20210509022148622

6、知识回顾与重要考点

image-20210509015408768

3.2.4、页面分配策略

image-20210509022248848

1、页面分配、置换策略

image-20210509022843592

image-20210509023042278

image-20210509023615272

image-20210509023233829

2、何时调入页面

image-20210509022356148

预调页策略和请求调页策略一般会结合着进行使用。

image-20210509022420725

image-20210509022441309

image-20210509022505874

3、抖动(颠簸)现象

image-20210509022535523

4、工作集

image-20210509022604479

脑图

image-20210509022634991

第四章 文件管理

在这里插入图片描述

4.1.1、初识文件管理

image-20210510172015086

1、Windows操作系统的文件管理

image-20210510173929518

2、文件的属性

image-20210510172305330

image-20210510172426075

3、文件内部的数据应该怎样组织起来?

image-20210510173839817

image-20210510173958010

4、文件之间应该怎样组织起来?

image-20210510174052984

image-20210510174111553

5、操作系统应该向上提供哪些功能?

image-20210510174132287

image-20210510174152635

6、从上往下看,文件应如何存放在外存?

image-20210510174208743

image-20210510174229192

7、其他需要由操作系统实现的文件管理功能

image-20210510174244099

脑图

image-20210510174258642

4.1.2、文件的逻辑结构

image-20210510174403271

1、无结构文件

image-20210510174420173

2、有结构文件

image-20210510174420173

image-20210510174511007

image-20210510174533798

3、有结构文件的逻辑结构

image-20210510174614541

4、顺序文件

image-20210510174647781

image-20210510174712891

5、索引文件

image-20210510174742627

6、索引顺序文件

image-20210510174824341

7、索引顺序文件(检索效率分析)

image-20210510174900571

8、多级索引顺序文件

image-20210510174937291

脑图

image-20210510175000352

image-20210510175034205

4.1.3、文件目录

image-20210510180705930

image-20210510180724124

1、文件控制块

image-20210510180808309

image-20210510181628502

image-20210510181647855

2、目录结构——单级目录结构

image-20210510181719480

3、目录结构——两级目录结构

image-20210510181749558

4、目录结构——多级目录结构

image-20210510181840085

image-20210510181919035

image-20210510181942460

5、目录结构——无环图目录结构

image-20210510182039884

6、索引结点(FCB的改进)

image-20210510182124127

image-20210510182257634

脑图

image-20210510182328384

4.1.4、文件的物理结构(文件分配方式)

image-20210510184057179

image-20210510184143772

1、文件块、磁盘块

image-20210510184202356

image-20210510184234195

2、文件分配方式——连续分配

image-20210510184517016

image-20210510194304195

image-20210510194321776

image-20210510194418244

总结:

image-20210510194438017

3、文件分配方式——链接分配

链接分配采取离散分配的方式,可以为文件分配离散的磁盘块。分为隐式链接显式链接两种。

4、链接分配——隐式链接

image-20210510200010895

image-20210510200055678

image-20210510200134964

5、链接分配——显式链接

image-20210510200212233

image-20210510200226440

6、链接分配(总结)

image-20210510200242086

7、文件分配方式——索引分配

image-20210510200330776

image-20210510200346364

image-20210510200418312

1、索引分配——链接方案

image-20210510200434973

2、索引分配——多层索引

image-20210510200449834

3、索引分配——混合索引

image-20210510203950212

8、索引分配(总结)

image-20210510200553830

9、知识点回顾与重要考点

image-20210510200625936

10、易混难点:支持随机访问

image-20210510200653421

4.1.5、文件存储空间管理

image-20210510204351623

image-20210510204422297

1、存储空间的划分与初始化

image-20210510204534899

2、存储空间管理——空闲表法

分配:

image-20210510204736595

回收:

image-20210510204759316

image-20210510204857398

image-20210510204918112

3、存储空间管理——空闲链表法

image-20210510205032206

1、空闲链表法——空闲盘块链

分配与回收:

image-20210510205148716

2、空闲链表法——空闲盘区链

分配与回收:

image-20210510205259520

4、存储空间管理——位示图法

image-20210510205536697

分配与回收:

image-20210510205642666

5、存储空间管理——成组链接法

image-20210510205738193

分配:

image-20210510210218546

image-20210510210320053

image-20210510210351181

回收:

image-20210510210747340

image-20210510210839939

image-20210510210915503

脑图

image-20210510211017588

4.1.6、文件的基本操作

image-20210510211109649

1、创建文件(create系统调用)

image-20210510211530217

2、删除文件(delete系统调用)

image-20210510211600032

3、打开文件(open系统调用)

image-20210510211623264

image-20210510211657842

4、关闭文件(close系统调用)

image-20210510211854855

5、读文件(read系统调用)

image-20210510211726508

6、写文件(write系统调用)

image-20210510211835280

脑图

image-20210510211746762

4.1.7、文件共享

image-20210510213241349

1、基于索引结点的共享方式(硬链接)

image-20210510213311954

2、基于符号链的共享方式(软链接)

image-20210510213347172

image-20210510213403610

image-20210510213429991

image-20210510213445471

image-20210510213503692

脑图

image-20210510213537878

4.1.8、文件保护

image-20210510213617783

1、口令保护

image-20210510214250894

2、加密保护

image-20210510214328225

image-20210510214402046

3、访问控制

image-20210510214438312

image-20210510214452912

4、Windows的访问控制

image-20210510214521631

image-20210510214610135\

image-20210510214635252

image-20210510214651156

脑图

image-20210510214712283

4.1.9、文件系统的层次结构

1、文件系统的层次结构

image-20210510215918389

2、知识点回顾与重要考点

image-20210510220046997

4.2.1、磁盘的结构

image-20210510214754390

1、磁盘、磁道、扇区

image-20210510220120936

2、如何在磁盘中读/写数据

image-20210510220141125

3、盘面、柱面

image-20210510220307505

image-20210510220318911

4、磁盘的物理地址

image-20210510220334460

5、磁盘的分类

image-20210510220354743

image-20210510220409094

脑图

image-20210510220429976

4.2.2、磁盘调度算法

image-20210510220523176

1、一次磁盘读/写操作需要的时间

image-20210510221358877

image-20210510221414332

image-20210510221430472

image-20210510221444551

2、先来先服务算法(FCFS)

image-20210510221458167

3、最短寻找时间优先(SSTF)

image-20210510221515203

4、扫描算法(SCAN)

image-20210510221530897

5、LOOK调度算法

image-20210510221552395

6、循环扫描算法(C-SCAN)

image-20210510221622445

7、C-LOOK调度算法

image-20210510221637101

脑图

image-20210510221654417

4.2.3、减少磁盘延迟时间的方法

image-20210510221738068

1、减少延迟时间的方法:交替编号

image-20210510223254723

2、磁盘地址结构的设计

image-20210510223311665

image-20210510223329605

image-20210510223343018

3、减少延迟时间的方法:错位命名

image-20210510223402335

image-20210510223414358

脑图

image-20210510223427091

4.2.4、磁盘的管理

image-20210510224338889

1、磁盘初始化

image-20210510224546394

2、引导块

image-20210510224644253

image-20210510224801174

3、坏块的管理

image-20210510224912088

脑图

image-20210510224948374

第五章 I/O管理

img

5.1.1、I/O设备的概含和分类

image-20210511110804479

1、什么是I/O设备

image-20210511111043854

image-20210511111059679

2、I/O设备的分类——按使用特性

image-20210511111151164

3、I/O设备的分类——按传输速率分类

image-20210511111340325

4、I/O设备的分类——按信息交换的单位分类

image-20210511111357178

脑图

image-20210511111317058

5.1.2、IO控制器

image-20210511111508063

1、I/O设备的机械部件

image-20210511112240342

2、I/O设备的电子部件(I/O控制器)

image-20210511112302574

3、I/O控制器的组成

image-20210511112323795

image-20210511112339935

4、内存映像I/O VS 寄存器独立编址

image-20210511112409008

脑图

image-20210511111721235

5.1.3、IO控制方式

image-20210511112526466

1、程序直接控制方式

image-20210511114118768

image-20210511114144636

image-20210511114203481

2、中断驱动方式

image-20210511114219032

image-20210511114237657

3、DMA方式

image-20210511114254744

DMA控制器

image-20210511114311516

image-20210511114331876

4、通道控制方式

image-20210511114349325

image-20210511114404292

5、知识点回顾与重要考点

image-20210511114426856

5.1.4、IO软件层次结构

image-20210511114507050

1、用户层软件

image-20210511120145365

2、设备独立性软件

image-20210511120201000

image-20210511120214522

image-20210511120232118

image-20210511120247174

image-20210511120308807

image-20210511120347783

image-20210511120406389

image-20210511120422617

3、思考:为何不同的设备需要不同的设备驱动程序?

image-20210511120439359

image-20210511120454305

image-20210511120511147

image-20210511120524487

4、设备驱动程序

image-20210511120542492

5、中断处理程序

image-20210511120601060

1、知识点回顾与重要考点

image-20210511120620181

image-20210511120633229

2、中断处理程序

image-20210511120645686

5.1.5、I/O核心子系统

image-20210511200617750

1、这些功能要在哪个层次实现?

image-20210511201151526

2、I/O调度

image-20210511201224315

3、设备保护

image-20210511201256428

4、知识总览

image-20210511201346880

5.1.6、假脱机技术

image-20210511201414648

1、什么是假脱机技术

image-20210511201503446

image-20210511201645994

2、假脱机技术——输入井和输出井

image-20210511201850384

image-20210511201805698

image-20210511201917534

image-20210511201943528

3、假脱机技术——输入/输出缓冲区

image-20210511202055139

4、共享打印机原理分析

image-20210511202209054

image-20210511202654105

image-20210511202423159

脑图

image-20210511202456002

5.1.7、设备的分配与回收

image-20210511134320520

1、设备分配时应考虑的因素

image-20210511184505182

image-20210511184525700

image-20210511184540600

2、静态分配与动态分配

image-20210511184556436

3、设备分配管理中的数据结构

image-20210511184610941

image-20210511184623290

image-20210511184641395

image-20210511184653652

image-20210511184714960

4、设备分配的步骤

image-20210511185859000

image-20210511185912320

image-20210511185925978

image-20210511185938379

5、设备分配步骤的改进

image-20210511185955148

image-20210511190008933

image-20210511190025223

脑图

image-20210511190059629

5.1.8、缓冲区管理

image-20210511184150637

1、什么是缓冲区?有什么作用?

image-20210511190337006

2、缓冲区有什么作用?

image-20210511190349446

3、单缓冲

image-20210511190403433

image-20210511190415564

image-20210511190430654

image-20210511190447737

4、双缓冲

image-20210511190502280

image-20210511190513456

image-20210511190526272

5、使用单/双缓冲在通信时的区别

image-20210511190537821

image-20210511190551862

6、循环缓冲区

image-20210511190605054

7、缓冲池

image-20210511221515991

image-20210511221647687

image-20210511221825920

image-20210511221937054

脑图

image-20210511190720738

参考链接:

bilibili王道考研

操作系统思维导图—(零基础—思维导图详细版本及知识点)

[TOC]

第一章 概述

在这里插入图片描述


1.1.1 、概念、组成、功能和分类

  1. 计算机网络概念:

    一个将分散的、具有独立功能的计算机系统,通过通信设备(交换机、路由器)与线路连接起来,由功能完善的软件实现资源共享和信息传递的系统

    计算机网络是互连的、自治的计算机集合

    image-20210402030633726

  2. 计算机网络的功能

    1. 数据通信(连通性)
    2. 资源共享(硬件、软件、数据,三大资源共享)
    3. 分布式处理(多台计算机各自承担同一工作任务的不同部分 如:Hadoop平台)
    4. 提高可靠性(替代机)
    5. 负载均衡

    image-20210402031009538

  3. 组成

    1. 组成部分(硬件、软件、协议)

      1. 硬件:主机(端系统),链路(双绞线、光纤),通信设备(路由器,交换机)
      2. 软件:QQ,微信等
      3. 协议

      image-20210402031120533

    2. 工作方式

      1. 边缘部分:用户直接使用(C/S方式,P2P方式)
      2. 核心部分:为边缘部分服务(网络,路由器,交换机)

      image-20210402031159369

    3. 功能组成

      1. 通信子网:实现数据通信(OSI上三层)

      2. 资源子网:实现资源共享/数据处理(OSI下三层)

        传输层:是资源子网和通信子网的接口

      image-20210402031240261

    4. 计算机网络的分类

      1. 按分布范围分

        • 广域网(WAN,交换技术)

        • 城域网(MAN)

        • 局域网(LAN,广播技术)

        • 个人区域网(PAN)

      2. 按使用者分

        • 公用网
        • 专用网
      3. .按交换技术分

        • 电路交换
        • 报文交换
        • 分组交换
      4. 按拓扑结构分

        • 总线型
        • 星型
        • 环形
        • 网状型(常用于广域网)
      5. 按照传输技术分

        • 广播式网络(共享公共通信信道)
        • 点对点网络(使用分组存储转发和路由选择机制)

      image-20210402031749128

脑图:

在这里插入图片描述

1.1.2、 标准化工作及相关组织

  1. 标准化工作

    1. 标准的分类

      1. 法定标准

        由权威机构指定的正式的、合法的标准

        OSI

      2. 事实标准

        某些公司的产品在竞争中占据了主流,时间长了,这些产品中的协议和技术就成了标准

        TCP/IP

  2. RFC(Request For Comments)——因特网标准的形式

RFC要上升为因特网正式标准的四个阶段

  1. 因特网草案(Internet Draft)

    这个阶段还不是RFC文档,只是一个构思

  2. 建议标准(Proposed Standard)

    - 从这个阶段开始成为RFC文档
  3. 草案标准(Draft Standard)(现取消)

    • IETF、IAB审核
  4. 因特网标准(Internet Standard)

  5. 标准化工作的相关组织

    1. 国际标准化组织ISO

      OSI参考模型、HDLC协议

    2. 国际电信联盟ITU

      制定通信规则

    3. 国际电气电子工程师协会IEEE

      学术机构、IEEE802标准、5G

    4. Internet工程任务组IETF

      负责因特网相关标准的制定 RFC—XXXX

      image-20210405164611745

脑图:

image-20210405164728541

1.1.3、 计算机网络的相关性能指标

  1. 速率

    即数据率或称数据传输率或比特率

    连接在计算机网络上的主机在数字信道上传输数据位数的速率

    补充:

    ​ 速率的单位(千、兆、吉、太)

    ​ 1Tb/s=10^3Gb/s=10^6Mb/s=10^9kb/s=10^12b/s

    ​ 存储容量单位:

    ​ 1Btye=8 bit 1TB/s=2^10 GB/s=2^20 MB/s=2^30 KB/s=2^40 B/s

  2. 带宽

    原本指某个信号具有的频带宽度,即最高频率与最低频率之差,单位是赫兹(Hz)

    在计算机网络中,带宽用来表示网络的通信线路传送数据的能力

    通常是指单位时间内从网络的某一点到另一点所能通过的“最高数据率”

    单位与速率的单位相同。(比特每秒、b/s,kb/s,Mb/s,Gb/s)

    可以理解为:网络设备所支持的最高速度,即:发送的速率

  3. 吞吐量

    表示在单位时间内通过某个网络(信道、接口)的数据量。单位b/s,kb/s,Mb/s等

    吞吐量受网络的带宽或网络的额定速率的限制

    所有的链路加一起才是本次网络的数据的真正吞吐量

  4. 时延

    指数据(报文/分组/比特流)从网络(或链路)的一端传送到另一端所需时间。
    也叫延迟或者迟延,单位是s

    1. 发送时延/传输时延 = 数据长度/信道带宽(发送速率)

      从发送分组的第一个比特算起,到该分组的最后一个比特发送完毕所需的时间。

    2. 传播时延 = 信道长度/电磁波在信道上的传播速率;
      取决于电磁波传播速度和链路长度

      此处注意:传输时延传播时延的区别

      • 传输时延:发生在主机内部,一般是发生在网络适配器当中,发生在机器内部的发送器里面
      • 传播时延:发生在机器外部,发送在信道上
    3. 排队时延

      等待输出/输入链路可用

      路由器的缓存空间那里

    4. 处理时延

      • 检错找出口
    5. 时延抖动

      时延的不均匀性

    注意:高速链路(提高发送速率/信号带宽)只是降低了发送时延,对传播时延和传播速率没有影响

  5. 时延带宽积 = 传播时延 * 带宽

    时延带宽积又称为比特位长度的链路长度

    即:某段链路现在有多少比特,有“容量”的意思

    image-20210405173609630

  6. 往返时延RTT

    发送发发送数据开始,到发送方收到接收方的确认(接收方收到数据后立即发送确认)总共经历的时延RTT越大,在收到确认之前,可以发送的数据越多

    RTT包括

    1. 往返传播时延 = 传播时延 * 2
    2. 末端处理时间

    注意:RTT不包括传输时延

  7. 利用率

    • 信道利用率 = 有数据通过时间 / (有 + 无)数据通过的时间

    • 网络利用率 = 信道利用率加权平均值

    • 利用率如果趋近于1,时延会急剧增大

      这些性能指标可以分为三类

      1. 速率、带宽、吞吐率
      2. 时延、时延带宽积、往返时延RTT
      3. 利用率(利用率如果趋近于1,时延会急剧增大)

脑图:

11

1.2.1、 分层结构、协议、接口、服务

  1. 为什么要分层?

    发送文件前要完成的工作:

    1. 发起通信的计算机必须将数据通信的通路进行激活。
    2. 要告诉网络如何识别目的主机。
    3. 发起通信的计算机要查明目的主机是否开机,并且与网络连接正常。
    4. 发起通信的计算机要弄清楚,对方计算机中文件管理程序是否已经做好准备工作。
    5. 确保差错和意外可以解决。
    6. …….

    所以,在发送文件的过程中,会出现很多问题,需要把这些问题分成一个个小问题,然后解决

  2. 怎么分层

    • 实体、对等实体
    • 对等实体之间才会有协议
    • 上下层之间的接口
    • 下层给上层提供服务
  3. 分层的基本原则

    1. 各层之间相互独立,每层只实现一种相对独立的功能
    2. 每层之间的界面自然清晰,易于理解,相互交流尽可能少
    3. 结构上可分隔开。每层都采用最合适的技术来实现
    4. 保持下层上层的独立性,上层单向使用下层提供的服务
    5. 整个分层结构应该促进标准化工作。
  4. 正式认识分层结构

    1. 实体:第n层中的活动元素称为n层实体。同一层的实体叫对等实体
    2. 协议:为进行网络中的对等实体数据交换为建立的规则、标准或约定称为网络协议。【水平
      • 语法:规定传输数据的格式
      • 语义:规定所要完成的功能
      • 同步:规定各种操作的顺序
    3. 接口(访问服务点SAP):上层使用下层服务的入口。
    4. 服务:下层为相邻上层提供的功能调用。【垂直
    5. SDU、PCI、PDU
      • SDU服务数据单元:为完成用户所需要的功能而应传输的数据。
      • PCI协议控制信息:控制协议操作的信息。
      • PDU协议数据单元:对等层次之间传送的数据单位。
      • PDU=SDU+PCI

    image-20210405222724021

总结:

  • 网络体系结构是从功能上描述计算机网络结构。
  • 计算机网络体系结构简称网络体系结构是分层结构
  • 每层遵循某个/些网络协议以完成本层功能。
  • 计算机网络体系结构是计算机网络的各层及其协议的集合。
  • 第n层在向n+1层提供服务时,此服务不仅包含第n层本身的功能,还包含由下层服务提供的功能
  • 仅仅相邻层间有接口,且所提供服务的具体实现细节对上一层完全屏蔽。
  • 体系结构是抽象的,而实现是指能运行的一些软件和硬件。

脑图:

image-20210405223601395

1.2.2、 OSI参考模型

  1. 计算机网络分层结构

    • 7层OSI参考模型(法定标准)
    • 4层TCP/IP参考模型(事实标准)
    • 5层体系结构(主要是使我们学习计算机网络更加清晰,不是事实标准,也不是法定标准)
  2. OSI参考模型是怎么来的?
    提出第一个网络体系结构:SNA(IBM公司)之后,很多公司和机构纷纷提出自己的网络体系结构:DEC公司的DNA,美国国防部的TCP/IP。为了支持异构网络系统的互联互通,国际标准化组织(ISO)于1984年提出开放系统互连(OSI)参考模型。但是,理论成功,市场失败。

  3. OSI7层结构

    1. 物理层、数据链路层、网络层、传输层、会话层、表示层、应用层
      物、链、网、输、会、示、用
      物联网淑慧试用
    2. 资源子网(数据处理):上三层:会话层、表示层、应用层
    3. 通信子网(数据通信):下三层:物理层、数据链路层、网络层
  4. OSI参考模型解释通信过程

    在这里插入图片描述

    上四层实现的是端到端的通信
    下三层实现的是点到点的通信

    在这里插入图片描述

  5. 各层功能与协议

    1. 应用层
      用户与网络的界面,所有能和用户交互产生网络流量的程序
      典型应用层服务:

      • 文件传输(FTP)
      • 电子邮件(SMTP)
      • 万维网(HTTP)等
    2. 表示层
      用于处理在两个通信系统中交换信息的表示方式(语法和语义)

      • 功能1:数据格式变换(翻译官)
      • 功能2:数据加密和解密
      • 功能3:数据压缩和解压缩

      没有专门的协议,硬要说的话:主要协议有JPEG、ASCII

    3. 会话层
      向表示层实体/用户进程提供建立连接并在连接上有序传输数据
      这是会话,也是建立同步(SYN)

      • 功能1:建立、管理、终止会话
      • 功能2:使用校验点可以使会话在通信失效时,从校验点/同步点继续恢复通信,实现数据同步。(适用于传输大文件)
        主要协议:ADSP、ASP
    4. 传输层
      负责两个不同主机中两个进程的通信,即端到端的通信。传输单位是报文段或用户数据报。(功能:可差流用)

      • 功能1:可靠传输、不可靠传输
      • 功能2:差错控制
      • 功能3:流量控制
      • 功能4:复用分用
        • 复用:多个应用层进程可同时使用下面运输层的服务。
        • 分用:运输层把收到的信息分别交付给上面应用层中相应的进程

      主要协议:TCP、UDP

    5. 网络层(最重要)
      主要任务是把分组从源端传到目的端,为分组交换网上的不同主机提供通信服务。

      网络层传输单位是数据报

      数据报过长时,会将数据报切割成一个个小的分组,再放到链路上传递

      • 功能1:路由选择(最佳路径)
      • 功能2:流量控制(协调发送端和接收端的速度)
      • 功能3:差错控制(奇偶校验等)
      • 功能4:拥塞控制
        若所有结点都来不及接收分组,而要丢弃大量分组的话,网络就处于拥塞状态。因此要采取一定措施缓解这种拥塞。

      主要协议:IP、IPX、ICMP、IGMP、ARP、RARP、OSPF

    6. 数据链路层

      主要任务是把网络层传下来的数据报组装成帧

      数据链路层/链路层的传输单位是

      • 功能1:成帧(定义帧的开始和结束)
      • 功能2:差错控制(帧错+位错)
      • 功能3:流量控制
      • 功能4:访问(接入)控制 控制对信道的访问

      主要协议:SDLC、HDLC、PPP、STP

    7. 物理层

      傻瓜层

      把比特流转成电信号的形式,然后放到链路上面进行传输,不需要对数据进行改动。
      主要任务是在物理媒体上实现比特流的透明传输,传输单位是比特
      透明传输:指不管所传数据是什么样的比特组合,都应当能够在链路层上传送。

      • 功能1:定义接口特性
      • 功能2:定义传输模式(单工、半双工、双工)
      • 功能3:定义传输速率
      • 功能4:比特同步
      • 功能5:比特编码

      主要协议:Rj45、802.3

脑图:

image-20210405234108810

1.2.3、 TCP/IP参考模型和5层参考模型

先有TCP/IP协议栈再有TCP/IP参考模型

image-20210405233009957

  1. OSI参考模型与TCP/IP参考模型相同点

    1. 都分层
    2. 基于独立的协议栈的概念
    3. 都可以实现异构网络互联
  2. OSI参考模型与TCP/IP参考模型的不同点

    1. OSI定义三点:服务、协议、接口

    2. OSI先出现,参考模型先于协议发明,不偏向特定协议

    3. TCP/IP设计之初就考虑到异构网互联问题,将IP作为重要的层次

    4. OSI VS TCP/IP

      OSI TCP/IP
      网络层 无连接+面向连接 无连接
      传输层 面向连接 无连接+面向连接

      面向连接:分为三个阶段

      1. 建立连接,发出一个建立连接的请求
      2. 连接成功之后,开始数据传输
      3. 数据传输完毕,释放连接

      无连接:直接进行数据传输

    5. 5层参考模型

      综合了OSI和TCP/IP的优点

      5层参考模型的分层及每层的功能:

      在这里插入图片描述

      5层参考模型的数据封装与解封装过程:

      在这里插入图片描述

1.3 第一章总结

在这里插入图片描述

第二章 物理层

在这里插入图片描述


第二章要学习的主要内容

  • 通信基础
  • 两个公式lim(考研重点)
  • 看图说话(数字信号的波形)
  • 传输介质
  • 物理层设备(中继器、集线器)

2.1.1、 物理层基本概念

  1. 物理层基本概念

    物理层解决如何在连接各种计算机的传输媒体上传输数据比特流,而不是指具体的传输媒体(传输媒体可以看做是第0层,要与物理层分开看)

    物理层的主要任务:确定与传输媒体接口有关的一些特性,定义标准

    物理层定义了哪些特性?

    • 机械特性
      定义物理连接的特性,规定物理连接时所采用的的规格、接口形状、引线数目引脚数量和排列情况
    • 电气特性
      规定传输二进制位时,线路上信号的电压范围、阻抗匹配、传输速率距离限制等。
    • 功能特性
      指明某条线上出现的某一电平表示何种意义,接口部件的信号线的用途
      比如:描述一个物理层接口引脚处于高电平时的含义。
    • 规程特性
      (过程特性)定义各条物理线路的工作规程和时序关系。

2.1.2 、数据通信基础知识

典型的数据通信模型:

在这里插入图片描述

  1. 数据通信相关术语

    通信的目的是传送消息

    • 数据:传送信息的实体,通常是有意义的符号序列。

    • 信号:数据的电气/电磁的表现,是数据在传输过程中的存在形式

      • 数字信号:代表消息的参数取值是离散
      • 模拟信号:代表消息的参数取值是连续
    • 信源:产生和发送数据的源头

    • 信宿:接收数据的终点

    • 信道:信号的传输媒介。一般用来表示向某一个方向传送信息的介质,因此一条通信线路往往包含一条发送信道和一条接收信道.

      信道分类

      • 传输信号分:数字信道(传送数字信号)、模拟信道(传送模拟信号)
      • 传输介质分:无线信道、有线信道
  2. 三种通信方式

    从通信双方信息的交互方式看,可以有三种基本方式

    • 单工通信
      只有一个方向的通信而没有反方向的交互,仅需要一条信道
    • 半双工通信
      通信的双方都可以发送或接收信息,但任何一方都不能同时发送和接收,需要两条信道
    • 全双工通信
      通信双方可以同时发送和接受信息,也需要两条信道
  3. 两种数据传输方式

    数据在信道上的传送方式

    • 串行传输
      速度慢,费用低、适合远距离
    • 并行传输
      速度快、费用高、适合近距离
      用于计算机内部数据传输(打印机,扫描机)

2.1.3 、码元、波特、速率、带宽

  1. 码元
    指用一个固定时长信号波形(数字脉冲),代表不同离散数值的基本波形,是数字通信中数字信号的计量单位,这个时长内的信号称为k进制码元,而该时长称为码元宽度。当码元的离散状态有M个时(M>2),此时码元为M进制码元。

    1码元可以携带多个比特的信息量。例如,在使用二进制编码时,只有两种不同的码元,一种代表0状态,另一种表示1状态。而四进制码元,一个码元可以携带2bit信息。(00/01/10/11)

    image-20210406001148097

  2. 速率、波特

    速率也叫数据率,是指数据的传输速率,表示单位时间内传输的数据量。可以用码元传输速率信息传输速率表示。

    1. 码元速率:(码元速率、波形速率、调制速率符号速率等等)
      它表示单位时间内数字通信系统所传输的码元个数(也可称为脉冲个数信号变化的次数),单位是波特(Baud)。1波特表示数字通信系统每秒传输一个码元。这里的码元可以是多进制的,也可以是二进制的,但是码元速率与进制数无关

      即:1s传输了多少码元

    2. 信息速率:
      表示单位时间内数字通信系统传输的二进制码元个数(即比特数)
      单位是比特/秒(b/s)

      即:1s传输多少比特

      关系:

      *若一个码元携带n bit的信息量,则M Buad的码元传输速率所对应的信息传输速率为Mn bit/s**

      即:*信息传输速率 = n bit * 码元传输速率*

      系统传输的是比特流,通常比较的的是信息传输速率

  3. 带宽
    表示在单位时间内从网络中的某一点到另一点所能通过的“最高数据率”,常用来表示网络的通信线路所能传输数据的能力。单位是b/s。

  4. 相关习题:

    image-20210406002432454

2.1.4、 奈氏准则和香农定理

  1. 失真:

    在这里插入图片描述

  2. 影响失真的因素:

    1. 码元传输速率(正相关)
      速率越快,信号失真程度越严重
    2. 信号传输距离(正相关)
      距离越远,衰减越久,干扰越久,对信号影响越大
    3. 噪声干扰(负相关)
      噪声越多,信号失真程度越大
    4. 传输媒体质量(负相关)
      传输媒体质量越差,越失真
  3. 失真的一种现象——码间串扰

    在这里插入图片描述

    信道带宽:信道能通过的最高频率和最低频率之差

    上图的信道带宽是:3300Hz-300Hz=3000Hz

    1. 速率过低为什么不能通过信道?

      速度太低,信号在信道上非常容易衰减

    2. 速度过高为什么不能通过信道?

      振动频率太快了,接收端在接收时区分不出来波形之间的差异(即:码间串扰)

      码间串扰:接收端收到的信好波形失去了码元之间清晰界限的现象

  4. 奈氏准则(奈奎斯特定理)

    在理想低通(无噪声,带宽受限)条件下,为了避免码间串扰,极限码元传输速率为2W Baud,W是信道带宽,单位是Hz在奈氏准则和香农定理中带宽的单位是Hz(不是bit/s)

    理想低通信道下的极限传输率=2Wlog2V(b/s)
    V:码元的种数/码元的离散电平数目

    根据奈氏准则可以得到以下4条结论:

    1. 在任何信道中,码元传输的速率是有上限的。若传输速率超过此上限,就会出现严重的码间串扰问题,使接收端对码元的完全正确识别成为不可能。
    2. 信道的频带越宽(即:能通过的信号高频分量越多),就可以用更高的速率进行码元的有效传输
    3. 奈氏准则给出了码元传输速率的限制,但并没有对信息传输速率给出限制
    4. 由于码元的传输速率受奈氏准则的制约,所以要提高数据的传输速率,就必须设法使每个码元能携带更多个比特的信息量,这就需要采用多元制的调制方法

    例:在无噪声的情况下,若某通信链路的带宽为3kHz,采用4个相位,每个相位具有4种振幅的QAM调制技术,则该通信链路的最大数据传输率是多少?

    分析:调相与调幅相结合

    解:

     信号有4 * 4=16种变化,则V=16;

    ​ 即:极限传输速率=2Wlog2(V)(b/s)= 2 * 3000 * log2(16)(b/s)= 24000(b/s)

  5. 香农定理

    噪声存在于所有的电子设备和通信系统中。由于噪声随机产生,它的瞬时值有时会很大,因此噪声会使接收端对码元的判决产生错误。但是噪声的影响是相对的,若信号较强,那么噪声影响相对较小。因此,信噪比就很重要。

    信噪比 = 信号的平均功率/噪声的平均功率,常记为S/N,并用分贝(dB)作为度量单位,即:
    信噪比(dB)=10 * log10(S/N)

    两者在数值上等价。

    而往往信噪比的这个值会很大,所以一般取对数。取了对数这个严格来说就叫做声强级,取对数实际上获得了次方的值,进而得到了声音(信息)的强度。但是两个东西表示的是同一信息。声强级是为了方便读数理解而对信噪比进行的变换(类似科学记数法)

    香农定理:

    带宽受限有噪声的信道中,为了不产生误差,信息的数据传输速率有上限值。

    信道的极限数据传输速率=W * log2(1+S/N) (b/s)

    S:信道所传信号的平均功率

    N:信道内的高斯噪声功率

    S/N即:信噪比

    W:带宽(Hz)

    image-20210406010014399

    根据香农定理可以得到以下5条结论:

    1. 信道的带宽或信道中的信噪比越大,则信息的极限传输速率就越高
    2. 对一定的传输带宽和一定的信噪比,信息传输速率的上限就确定了
    3. 只要信息的传输速率低于信道的极限传输速率,就一定能找到某种方法实现无差错传输
    4. 香农定理得出的为极限信息传输速率,实际信道能达到的传输速率要比它低不少
    5. 从香农定理可以看出,若信道带宽W或信噪比S/N没有上限(不可能),那么信道的极限信息传输速率也就没有上限。

    例题:

    image-20210406010235606

  6. 奈氏准则和香农定理的联系与区别:

    奈氏准则(内忧,码间串扰)香农定理(外患,外界噪声)
    有时候既需要使用奈氏准则,也需要使用香农定理,这时,需要取两者中的最小值

    image-20210406010414253

    例:二进制信号在信噪比为127∶1的4kHz信道上传输,最大的数据速率可达到多少?
    解:

    由奈氏准则知:最大数据传输速率为=2 * W * log2(V)
                              =2 * 4000 * 1=8000(b/s)
    有香农定理知:最大数据传输速率为=W * log2(1+S/N)
                              =4000 * log2(1+127)
                              =28000(b/s)
    最大的数据传输率为8000(b/s)

2.1.5 、编码与调制

  1. 基带信号与宽带信号

    信道上传送的信号

    1. 基带信号:将数字信号1和0直接用两种不同的电压表示,再送到数字信道上去传输(基带传输
      基带信号是来自信源的信号,就像计算机输出的代表各种文字或者图像文件的数据信号都属于基带信号。
      基带信号就是发出的直接表达了要传输的信息的信号,比如我们说话的声波就是基带信号。
    2. 宽带信号:将基带信号进行调制后形成的频分复用模拟信号,再传到模拟信道上去传输(宽带传输
      把基带信号经过载波调制后,把信号的频率范围搬迁到较高的频段一遍在信道中传输(即:仅在一段频率范围内能够通过信道)
      在传输距离较时,计算机网络采用基带传输方式(近距离衰减小,从而信号内容不易发生变化)
      在传输距离较时,计算机网络采用宽带传输方式(近距离衰减大,即使信号变化大也能最后过滤出来基带信号)

    image-20210407105303305

  2. 编码与调制

    数据–>数字信号 编码

    数据–>模拟信号 调制

    数字数据–数字发送器–>数字信号 编码

    数字数据–调制器–>模拟信号 调制

    模拟数据–PCM编码器–>数字信号 编码

    模拟数据–放大器调制器–>模拟信号 调制

  3. 四种编码与调制方法

    1. 数字数据编码为数字信号
    2. 数字数据调制为模拟信号
    3. 模拟数据编码为数字信号
    4. 模拟数据调制为模拟信号
  4. 数字数据编码为数字信号

    1. 非归零编码【NRZ】

      编码方式:高1低0

      编码容易实现,但没有检错功能,且无法判断一个码元的开始和结束,以至于收发双方难以保持同步。

      发送端全1或0,接收端都不好识别,需要确定时间周期

      不常用

    2. 曼彻斯特编码

      综合归零编码、非归零编码、反向不归零编码的优缺点而形成的非常优秀的编码

      它可以把时钟信号和数据都放在一块,不需要额外的信道传输时钟信号,就可以实现自己本身的同步,即:自同步
      编码方式:

      • 将一个码元分成两个相同的间隔,前一个间隔为低电平后一个为高电平表示码元1;
      • 码元0则正好相反。也可以采用相反的规定。

      该编码的特点是:在每一个码元的中间出现电平跳变,位于中间的跳变既作为时钟信号(用于同步),
      又作为数据信号,但它所占的频带宽度是原始的基带宽度的两倍。每一个码元都被调成两个电平,所以数据传输速率只有调制速率的1/2。

    3. 差分曼彻斯特编码(常用于局域网传输)

      编码方式:同1异0

      其规则是:若码元为1,则前半个码元的电平与上一个码元的后半个码元的电平相同,若为0,则相反。

      该编码的特点是:
      在每个码元的中间,都有一次电平的跳转,可以实现自同步,且抗干扰性强于曼彻斯特编码(因为实现算法更复杂)

    4. 归零编码【RZ】

      编码方式:信号电平在一个码元之内都要回复到零的方式

      全零不容易识别

      不常用

    5. 反向不归零编码【NRZI】

      编码方式:信号电平翻转表示0,信号电平不变表示1

      发送端全0,接收端容易识别,发送端全1,接收端不好识别

    6. 4B/5B编码

      比特流中插入额外的比特以打破一连串的0或1,就是用5个比特来编码4个比特的数据,之后再传给接收方,因此称为4B/5B。编码效率为80%

      只采用16种5位码对应16种不同的4位码,其他的16种5位码作为控制码(帧的开始或结束,线路的状态信息等)或保留。

      image-20210407110138680

    前三种重点掌握,后三种了解即可

  5. 数字数据调制为模拟信号

    数据调制技术:
    在发送端将数字信号转化为模拟信号,而在接收端将模拟信号还原为数字信号,分别对应于调制解调器的调制和解调过程。

    • 2ASK 调幅
      低电平0没有幅动,高电平1有幅动
    • 2FSK 调频
      低电平0低频,高电平1高频
    • 2PSK 调相
      0对应一种波形,1对应一种波形
    • QAM 调幅+调相
      例如:某通信链路的波特率是1200Baud,采用4个相位,每个相位有4种振幅的QAM调制技术,则该链路的信息传输速率是多少?
      解:
      信号有4 * 4 = 16种变化
      信息传输速率 = W * log2(V)= 1200 * 4 = 4800(b/s)

    在这里插入图片描述

  6. 模拟数据编码为数字信号

    1. 计算机内部处理的是二进制数据,处理的都是数字音频,所以需要将模拟音频通过采样、量化转换成有限个数字表示的离散序列(即实现音频数字化)。

    2. 最典型的例子就是对音频信号进行编码的脉码调制(PCM脉码调制),在计算机应用中,能够达到最高保真水平的就是PCM编码,被广泛用于素材保存及音乐欣赏,CD、DVD以及我们常见的WAV文件中均有应用。

      它主要包括三步:抽样、量化、编码。

      1. 抽样

        对模拟信号周期性扫描,把时间上连续的信号变成离散的信号。

        为了使所得的离散信号能够无失真地代表被抽样的模拟数据,要使用采样定理进行采样。

        采样定理:(奈奎斯特采样定理)
        f采样频率 >= 2 * f信号最高频率
        (最高分波形上至少采样两个点)

      2. 量化

        把抽样取得的电平幅值按照一定的分级标度转化为对应的数字值,并取整数,这就把连续的电平幅值转换为离散的数字量。

      3. 编码

        把量化的结果转换为与之对应的二进制编码

        image-20210407110815400

  7. 模拟数据调制为模拟信号
    为了实现传输的有效性,可能需要较高的频率。这种调制方式还可以使用频分复用技术,充分利用带宽资源。在电话机和本地交换机所传输的信号是采用模拟信号传输模拟数据的方式;模拟的声音数据是加载到模拟的载波信号中传输的。

脑图:

在这里插入图片描述

2.2.1、 物理层传输介质

  1. 传输介质及分类

    1. 传输介质也称传输媒体/传输媒介,它就是数据传输系统中在发送设备和接收设备之间的物理通路

    2. 传输媒体并不是物理层

      传输媒体在物理层的下面,因为物理层是体系结构的第一层,因此有时称传输媒体为0层。在传输媒体中传输的是信号,但传输媒体并不知道所传输的信号代表什么意思。但物理层规定了电气特性,因此能够识别所传送的比特流。

    3. 如果称物理层是傻瓜,那么传输媒体连傻瓜都不如

    4. 传输媒体分类

      1. 导向性传输媒体

        电磁波被导向沿着固体媒体(铜线/光纤)传播

      2. 非导向性传输媒体

        自由空间,介质可以是空气、真空、海水等。

  2. 导向性传输介质

    1. 双绞线:是古老、又最常用的传输介质,它由两根采用一定规则并排绞合的、相互绝缘的铜导线组成。

      绞合可以减少相邻导线的电磁干扰。

      为了进一步提高抗电磁干扰能力,可在双绞线的外面再加上一个由金属丝编织成的屏蔽层,这就是屏蔽双绞线(STP)

      无屏蔽层的双绞线就称为非屏蔽双绞线(UTP)。

      image-20210409094235649

      特点:

      • 双绞线价格便宜,是最常用的传输介质之一,在局域网和传统电话网中普遍使用。
      • 模拟传输和数字传输都可以使用双绞线,其通信距离一般为几公里到数十公里。
      • 距离太远时,对于模拟传输,要用放大器放大衰减的信号;
      • 对于数字传输,要用中继器将失真的信号整形
    2. 同轴电缆

      同轴电缆由导体铜质芯线绝缘层网状编织屏蔽层塑料外层构成。

      按特性阻抗数值的不同,通常将同轴电缆分为两类:50欧姆同轴电缆和75欧姆同轴电缆。

      其中,50欧姆同轴电缆主要用于传送基带数字信号,又称为基带同轴电缆,它在局域网中得到广泛应用;75欧姆同轴电缆主要用于传送宽带信号,又称为宽带同轴电缆,它主要用于有线电视系统。

      image-20210409094427395

      双绞线和同轴电缆的区别

      由于外导体屏蔽层的作用,同轴电缆抗干扰特性比双绞线,被广泛用于传输较高速率的数据,其传输距离更远,但价格较双绞线

    3. 光纤

      1. 光纤通信就是利用光导纤维(简称光纤)传递光脉冲来进行通信。有光脉冲表示1,无光脉冲表示O。

        而可见光的频率大约是10^8MHz,因此光纤通信系统的带宽远远大于目前其他各种传输媒体的带宽。

      2. 光纤在发送端有光源,可以采用发光二极管或半导体激光器,它们在电脉冲作用下能产生出光脉冲,在接收端用光电二极管做成光检测器,在检测到光脉冲时可还原出电脉冲。

      3. 光纤主要由纤芯(实心的!)和包层构成,光波通过纤芯进行传导,包层较纤芯有较低的折射率。当光线从高折射率的介质射向低折射率的介质时,其折射角将大于入射角。因此,如果入射角足够大,就会出现全反射,即光线碰到包层时候就会折射回纤芯、这个过程不断重复,光也就沿着光纤传输下去。

        超低损耗,传送超远距离

        image-20210409095419124

      4. 分类

        1. 多模光纤

          传播过程会有损耗,传播过程中会受到噪声的影响,如果距离过远可能会出现较为严重的失真,适合近距离传输

        2. 单模光纤

          单模与多模光纤的比较一根光缆少则只有一根光纤,多则包括十至数百根光纤

          在这里插入图片描述

      5. 光纤的特点

        1. 传输损耗小,中继距离长,对远距离传输特别经济
        2. 抗雷电和电磁干扰性能好
        3. 无串音干扰,保密性好,也不易被窃听或截取数据
        4. 体积小,重量轻
  3. 非导向性传输介质

    1. 无线电波

      信号向所有方向传播

      较强穿透能力,可传远距离,广泛用于通信领域(手机通信)

    2. 微波

      信号固定方向传播

      微波通信频率较高、频段范围宽,因此数据率很高

      1. 地面微波接力通信

        中继站

      2. 卫星通信

        同步卫星起到了中继站的作用

        • 优点
          1. 通信容量大
          2. 距离远
          3. 覆盖广
          4. 广播通信和多址通信
        • 缺点
          1. 传播时延长(250-270ms)
          2. 受气候影响大(eg:强风、太阳黑子爆发)
          3. 误码率较高
          4. 成本高
    3. 红外线、激光

      信号固定方向传播

      把要传输的信号分别转换为各自的信号格式,即红外光信号和激光信号,再在空间中传播。
      (微波不需要转换格式)

脑图:

在这里插入图片描述

2.2.2 、物理层设备

  1. 中继器

    1. 诞生的背景:由于存在损耗,在线路上传输的信号功率会逐渐衰减,衰减到一定程度时将会造成信号失真。

    2. 中继器的功能:对信号进行再生和还原,对衰减的信号进行放大,保持与原数据相同,以增加信号传输的距离,延长网络的长度。

简而言之:再生数字信号
3. 中继器的两端:

  1. 两端的网络部分是**网段**,而不是子网,适用于**完全相同的两类网络的互连**,且**两个网段速率要相同**。

  2. 中继器只将任何电缆段上的数据发送到另一段电缆上,它仅作用于信号的电气部分,并不管数据中是否有错误或不适于网段的数据。

  3. **两端可连相同的媒体,也可连不通的媒体**。

  4. 中继器两端的网段一定要是**同一个协议**。(**中继器不会存储转发**)
  1. 5-4-3规则:网络标准中都对信号的延迟范围作了具体的规定,因而中继器只能在规定的范围内进行,否则会网络故障。

    5个网段,4个网络设备,3个段可以连接计算机

    image-20210409164354784

  2. 集线器(多口中继器)

    1. 再生,放大信号

    2. 集线器的功能:对信号进行再生放大转发,对衰减的信号进行放大,接着转发到其他所有(除输入端口外)处于工作状态的端口上,以增加信号传输的距离,延长网络的长度。不具备信号的定向传送能力,是一个共享式设备

    3. 星型拓扑(广播通信)

      image-20210409165035181

    4. 集线器不能分割冲突域。当有超过两台主机同时发送数据给集线器就会发生信息碰撞,要等待随机一段时间之后在发送数据。当集线器连接的主机数目越来越多的时候,由于产生信息碰撞的概念变大,集线器的工作效率也会降低。连在集线器上的工作主机平分带宽。

2.3 、第二章总结

在这里插入图片描述

第三章 数据链路层

在这里插入图片描述


第三章学习的主要内容是:

  1. 链路层的功能
  2. 链路层的两种信道
  3. 局域网、广域网
  4. 链路层的设备

3.1.1、 数据链路层功能概述

  1. 数据链路层的研究思想:

    想象数据是直接从发送端的数据链路层,经过中间系统水平发送到接收端的数据链路层

  2. 数据链路层的基本概念

    1. 结点:主机、路由器

    2. 链路:网络中两个结点之间的物理通道,链路的传输介质主要有双绞线、光纤和微波。分为有线链路、无线链路。

    3. 数据链路:网络中两点之间的逻辑通道,把实际控制数据传输协议的硬件和软件加到链路上就构成了数据链路。

    4. 帧:链路层的协议数据单元,封装网络层的数据报

    数据链路层负责通过一条链路从一个节点向另一个物理链路直接相邻的相邻结点传送数据报。

  3. 数据链路层功能概述

    在物理层提供服务的基础之上向网络层提供服务,其最基本的服务是将源自网络层来的数据可靠地传输到相邻结点的目标机网络层。

    其主要作用是加强物理层传输原始比特流的功能,将物理层提供的可能出错的物理连接改造成逻辑上无差错的数据链路,使之对网络层表现为一条无差错的链路。

    • 功能1:为网络层提供服务。无确认连接服务有确认无连接服务有确认面向连接服务。(有连接一定有确认!)
      • 无确认连接服务:通常用于实时通信或者误码率比较低的通信信道。源主机在发送数据的时候不用事先与目的主机建立好链路的连接,而且目的主机收到数据帧的时候也不用返回确认。如果数据帧丢失了数据链路层也不负责重发,而直接交给上一层处理。(不负责但很快)
      • 有确认无连接服务:通常用于无线通信或者误码率比较高的通信信道。源主机在发送数据的时候不用事先与目的主机建立好链路的连接,但是目的主机收到数据帧的时候需要向源主机返回确认。如果源主机发现在规定时间内没有收到目的主机发送的确认信号,它就把刚才没有收到确认的这个数据帧重新发送,以此来提高数据链路层的可靠性。
      • 有确认面向连接服务:源主机在发送数据的时事先与目的主机建立好链路的连接,目的主机收到数据帧的时也向源主机返回确认。源主机发现确认信号才能发送下一个。(最安全最可靠但速度也是最慢的)
    • 功能2:链路管理,即连接的建立、维持、释放(用于面向连接的服务)
    • 功能3:组帧
    • 功能4:流量控制,限制发送方
    • 功能5:差错控制(帧错/位错)

3.1.2、 封装成帧和透明传输

  1. 封装成帧

    1. 概念:

      就是在一段数据的前后部分添加首部和尾部,这样就构成了一个帧。

      接收端在收到物理层上交的比特流后,就能根据首部和尾部的标记,从收到的比特流中识别帧的开始和结束。

      首部和尾部包含许多的控制信息,他们的一个重要的作用:帧定界(确定帧的界限)

    2. 帧同步:接收方应当能从接收到的二进制比特流中区分出帧的起始和终止。

    3. 组帧的四种方法

      1. 字符计数法
      2. 字符(节)填充法
      3. 零比特填充法
      4. 违规编码法
    4. 示意图

      在这里插入图片描述

  2. 透明传输

    指:不管所传数据是什么样的比特组合,都应当能够在链路上传送。

    因此,链路层就“看不见”有什么妨碍数据传输的东西。当所传数据中的比特组合恰巧与某一个控制信息完全一样时,必须采取适当的措施,使接收方不会将这样的数据错误认为是某种控制信息,这样才能保证数据链路层的传输是透明的。

  3. 字符计数法(不常用)

    帧首部使用一个计数字段(第一个字节,8位)来表明帧内字符数(字节数)。

    image-20210409182821894

    痛点:鸡蛋装在一个篮子里。

    如果第一个字节(计数字段)是错误的,则后面的帧全部发生错误。这样接收方没有办法正确接收每一个帧。

  4. 字符填充法

    当传送的帧是由文本文件组成时(文本文件的字符都是从键盘输入的,都是ASCII码),不管从键盘上输入什么字符都可以放在帧里面传过去,即透明传输

    image-20210409182945627

    当传送的帧是由非ASCII码的文本文件组成时(二进制代码的程序或图像等),就采用字符填充法实现透明传输。

    image-20210409183020592

    字符填充法的示意图:

    在这里插入图片描述

  5. 零比特填充法

    image-20210409192047830

    操作

  6. 在发送端,扫描整个信息字段(原始数据),只要连续5个1,就立即填入1个0

    即:5”1”,1”0”

  7. 在信息字段前后都加上0111110,作为帧的边界

  8. 在接收端收到一个帧时,先找到标志字段确定边界,再用硬件对比特流进行扫描。

    发现连续5个1时,就把后面的0删除。

    保证了透明传输:在传送的比特流中可以传送任意比特组合,而不会引起对帧边界的判断错误。

  9. 违规编码法

    对于曼彻斯特编码,可以使用高-高,低-低来定帧的起始和终止。

    局域网的IEE802标准就采用了该方法。

总结:

由于字节计数法中Count字段(第一个字节)的脆弱性(其值若有差错将导致灾难性后果)及字符填充实现上的复杂性和不兼容性,目前较普遍使用的帧同步法是比特填充违规编码法

3.1.3 、差错控制(比特错,检错编码,纠错编码)

  1. 差错从何而来?

    概括来说,传输中的差错都是由于噪声引起的。

    1. 全局性:由于线路本身电气特性所产生的的随机噪声(热噪声),是信道固有的,随机存在的。

      解决办法:提高信噪比来减少或避免干扰。(对传感器下手

    2. 局部性:外界特定的短暂原因所造成的冲击噪声,产生差错的主要原因。

      解决办法:通常利用编码技术来解决。

  2. 差错的分类

    1. 位错:比特位出错,1变成0,0变成1

    2. 帧错:分为三种:丢失,重复,失序

      例如:要传输三个帧[#1]-[#2]-[#3],则:

      • 帧丢失:[#1]-[#3]
      • 帧重复:[#1]-[#2]-[#2]-[#3]
      • 帧失序:[#3]-[#2]-[#1]

      针对这些帧错误,会采用帧编号、确认重传机制等来进行帧的差错控制。

      image-20210409195542838

      这是过去OSI模型的观点,现在通信链路的质量大大提高,因为通信链路质量不好引起的差错概率越来越小。

      现在的因特网会采用较为灵活的方法,针对不同的网络,我们会选择是否采用确认重传机制。

  3. 链路层为网络层提供的服务:

    1. 无确认无连接服务
    2. 有确认无连接服务
    3. 有确认面向连接服务

    若通信质量好,比如有线传输链路,链路层协议就不会采用确认和重传机制,而且也不要求链路层向网络层提供有效可靠传输的服务(即只有无确认无连接服务),如果发生差错,改错任务会交给上层协议(传输层)。

    若通信质量差,比如无线传输链路,链路层协议就会采用确认和重传机制数据链路层就需要向上提供可靠传输的服务(即需要提供有确认无连接服务和有确认面向连接服务)

  4. 数据链路层的差错控制(比特错,帧错会在后面讲解)

    差错控制:

    1. 检错编码
      1. 奇偶校验码
      2. 循环冗余码CRC
    2. 纠错编码
      1. 海明码
    3. 数据链路层编码和物理层编码的区别
      • 数据链路层编码和物理层的数据编码与调制不同
      • 物理层编码针对的是单个比特,解决传输过程中比特的同步等问题,如曼彻斯特编码。
      • 数据链路层的编码针对的是一组比特,它通过冗余码的技术实现一组二进制比特串在传输过程中是否出现了差错。
  5. 奇偶校验码

    ​ n-1位信息元,1位校验元

    1. 奇校验码

      信息元和校验元中,“1”的个数为奇数

    2. 偶校验码

      信息元和校验元中,“1”的个数为偶数

    奇偶校验码特点:只能检查出奇数个比特错误,检错能力为50%

  6. CRC循环冗余码

    1. 发送端最终发送的数据:要发送的数据+帧检验序列FCS

      计算冗余码(FCS帧检验序列)

      1. 第1步:加0 假设生成多项式G(x)的阶为r,则加r个0(多项式是n位,则阶是n-1位)

        加0是为了不改变原发送数据,FSC帧检验直接跟在原发送数据的后面即可

      2. 第2步:模2除法。数据加0后除以多项式,余数就是冗余码FCS。

        在除法过程中应该做减法的步骤,在模2除法中替换为异或

    2. 接收端检错过程

      把接收的每一帧都除以相同的除数(发送端的生成多项式),然后检查得到的除数R

      • 若R==0,判定这个帧没有差错,接受
      • 若R!=0,判断这个帧有差错(无法确认到位),丢弃
    3. FCS的生成以及接收端CRC检验都是由硬件实现,处理很迅速,因此不会延误数据的传输。

    4. 在数据链路层仅仅使用循环冗余检验CRC差错检测技术,只能做到对帧的无比特差错接收,即“凡是接收端数据链路层接受的帧,我们都能以非常接近于1的概率认为这些帧在传输过程中没有产生差错”。

      可以认为:“凡是接收端数据链路层接收的帧均无差错”

      接收端丢弃的帧虽然曾收到了,但是最终还是因为有差错被丢弃。

    5. 但是帧的无差错接受不是可靠传输,CRC循环冗余码只能检验出帧有错误并丢弃,但是不能对错误的帧进行校正。

      可靠传输是指:数据链路层发送端发送什么,接收端就收到什么。

  7. 海明码

    1. 发现双比特错,纠正单比特错

    2. 工作原理:牵一发而动全身

    3. 工作流程

      1. 确定校验码位数r

        海明不等式:2^r >= k+r+1 (r是冗余信息位,k为信息位)

      2. 确认校验码和数据的位置

        校验码只能填在2的n次方的位置(包括第一个位置)

        原码按顺序插入

      3. 求出校验码的值

        首先将数据位从低位到高位按1,2,3…进行编号,然后将编号用二进制表示,记录二进制表示中的第n位为1的数据位,令这些数据位上的上的数据异或=0,则Pn即为所求这些数据位上包括Pn,则公式可以改进为:Pn=这些数据位除了Pn之外异或(原理:相同异或为零)

        补充:异或的性质

        1. 任意二进制数与0异或之后是本身

        2. 任意二进制数与1异或之后是取反

        3. 偶数个1异或是0(2 * k个1,即在1的基础上进行2 * k-1次取反操作),

          奇数个1异或是1(2 * k+1个1,即在1的基础上进行2 * k次取反操作),

          无论0有多少个

      4. 检错并纠错

        记录二进制表示中的第n位为1的数据位,求这些数据位上的上的数据异或,则Jn即为所求
        J=JN…J1J2将J转换为十进制JT,即第JT位发生错误

    4. 比如:D=101101

      1. 第1步:先求出校验码的位数:2^4=16>=4+6+1,所以校验码为4位

      2. 第2步:按位数分别给校验码、原码编号,原码一共有6位,即编号为:D6 D5 D4 D3 D2 D1

        校验码一共有4位,即编号为P4 P3 P2 P1

        校验码和原码的位置分配:D6 D5 P4 D4 D3 D2 P3 D1 P2 P1

        ​ 位置编号 10 9 8 7 6 5 4 3 2 1
        ​ 位置二进制编码 1010 1001 1000 0111 0110 0101 0100 0011 0010 0001

      3. 第3步:求P1,D5 D4 D2 D1 P1的位置编码的第一位是1,所以令D5 D4 D2 D1 P1的异或=0,求出P1

        Pn的求法同理,先把能求的求出来,最后把之前不能求的再求出来

        求得:P1=P2=P3=0,P4=1

      4. 第4步:求得海明码1011100100

脑图:

在这里插入图片描述

3.1.4 、流量控制与可靠传输机制

  1. 数据链路层的流量控制

    1. 较高的发送速度较低的接收能力的不匹配,会造成传输出错,因此流量控制也是数据链路层的一项重要工作
    2. 流量控制在传输层也有
    3. 链路层与传输层流量控制的区别:
      1. 数据链路层的流量控制是点对点的,而传输层的流量控制是端到端的。
      2. 数据链路层流量控制的手段:接收方收不下就不回复确认
      3. 传输层流量控制手段:接收端给发送端一个窗口公告
  2. 流量控制的方法

    1. 停止等待协议(也可以算是一个特殊的滑动窗口协议,这种协议内发送和接收窗口都是1)

      每发送完一个帧就停止发送,等待对方的确认,在收到确认后再发送下一个帧。

      效率低

      发送窗口大小=1,接收窗口大小=1;窗口大小固定

      在这里插入图片描述

    2. 滑动窗口协议

      1. 后退N帧协议(GBN)

        发送窗口大小>1,接收窗口大小=1;窗口大小固定

      2. 选择重传协议(SR)

        发送窗口大小>1,接收窗口大小>1;窗口大小固定

  3. 可靠传输、滑动窗口、流量控制概念解析

    1. 可靠传输:发送端发啥,接收端收啥
    2. 流量控制:控制发送速率,使接收方有足够的换种空间来接收每一帧。
    3. 滑动窗口是解决流量控制(收不下就不给确认,想发也发不了)和可靠传输(发送方自动重传)的方式

3.1.4.1、 停止-等待协议

  1. 停止-等待协议究竟是哪一层?

    在早期的计算机网络中,由于通信链路质量差,出现差错比较多,为了提高传输效率,数据链路层应该承担一部分可靠传输的任务,把停止-等待协议放在了数据链路层。

    在现在的计算机网络中,通信链路质量大大提高,出现差错的情况很少,不用承担可靠传输的任务,提高了通信速度,降低了延迟。

    停止-等待协议放在了传输层链路层则主要负责差错控制

  2. 为什么要有停止-等待协议?

    1. 除了比特出差错,底层信道还会出现丢包问题

      丢包:物理线路故障、设备故障、病毒攻击、路由信息错误等原因会导致数据包的丢失
      (数据包其实就是一个数据,在数据链路层叫帧,在网络层就叫IP数据报或者分组,在传输层也可以叫报文段

    2. 为了解决丢包问题(可靠控制)和流量控制就出现了停止-等待协议

  3. 研究停止等待协议的前提?

    1. 虽然现在常用全双工通信方式,但为了讨论问题方便,仅考虑一方发送数据(发送方),一方接收数据(接收方)
    2. 因为是在讨论可靠传输的原理,所以并不考虑数据是在哪一层次上传送的
    3. “停止-等待”就是每发送完一个分组就停止发送没等待对方确认,在收到确认后再发送下一个分组。
  4. 停止等待协议有几种应用情况?

    1. 无差错情况

      image-20210409211423472

    2. 有差错情况

      1. 数据帧丢失和检测到帧出错

        1. 超时计时器:每发送一个帧就启动一个计时器

        2. 如果在计时器到期之前收到了确认帧,则计时器终止。

          如果计时器到期了还没有收到确认帧,则发送方会重新发送没收到确认帧的数据帧

        3. 超时计时器设置的重传时间应当比帧传输的平均RTT(往返传播时延)更长一些

        4. 注意事项

          1. 发送完一个帧后,必须保留它的副本。
          2. 数据帧和确认帧必须编号

        image-20210409211646062

      2. ACK丢失(确认帧丢失)

        发送方超时计时器到期后没有收到确认帧,发送方重传数据帧

        接收方收到了重复的数据帧,丢弃重复的数据帧,并重传确认帧

        image-20210409211747811

      3. ACK迟到(确认帧迟到)

        超时还没收到确认帧则重传数据帧,接收方收到了重复的数据帧,并丢弃重复的数据帧

        发送方之后在等待另一个确认帧时,收到了迟到的确认帧,会不对迟到的数据帧做处理

        image-20210409211901849

  5. 停止-等待协议性能分析

    1. 优点:简单

    2. 缺点:信道利用率太低

      image-20210409212059380

      信道利用率:发送方在一个发送周期内,有效地发送数据所需要的时间占整个发送周期的比率信道利用率 = (L/C)/ T

      • L:T内发送L比特数据
      • C:发送方数据传输率
      • T:发送周期,从发送数据开始,到收到第一个确认帧为止(一般包括发送时间和RTT,接收数据帧的时间可以忽略)

      信道吞吐率 = 信道利用率 * 发送方的发送速率

    例题:

    image-20210409212300872

脑图:

image-20210409212332589

3.1.4.2 、后退N帧协议(GBN)

停止等待协议的弊端:信道利用率太低,太闲了。

采用流水线技术对停止-等待协议(一个数据帧跟着数据帧发送)进行改进。

使用流水线技术后:

  1. 必须增加数据帧序号的范围
  2. 发送方需要缓存多个分组

所以出现了后退N帧协议(GBN)选择重传协议(SR)

  1. 后退N帧协议中的滑动窗口

    发送窗口:发送方维持一组连续的允许发送的帧的序号

    接收窗口:接收方维持一组连续的允许接收帧的序号。

    ​ 在后退N帧协议中,接收窗口只有一个

    ​ 在选择重传协议中,接收窗口有多个

    image-20210409213121135

  2. 后退N帧协议执行过程

    1. GBN发送方必须响应的三件事

      1. 上层(网络层)的调用

        上层要发送数据时,发送方先检查发送窗口是否已满,如果未满,则产生一个帧并将其发送;

        如果窗口已满,发送方只需将数据返回给上层,暗示上层窗口已满。上层等一会再发送(实际实现中,发送方可以缓存这些数据,窗口不满时再发送帧。)

      2. 收到了一个ACK

        GBN协议中,对n号帧的确认采用累计确认的方式,标明接收方已经收到n号帧和它之前的全部帧。

        累计确认:例如:接收方返回了一个对于3号帧的确认帧,而数据帧的编号也是从0号开始的(0/1/2/3/4/….)。如果接收方将一个3号帧对应的确认帧给发送方。发送方就知道接收方已经接收到3号帧以及3号帧以前的所有的帧(0/1/2帧)。也就是说0到3号帧接收方已经完全接收了。这就是累积确认方式。

        也就是说,在GBN协议当中,接收方不用对于每一个数据帧都逐个返回一个对应的确认帧。他可以隔一会在发送一个确认帧。它这个确认帧就是想告诉发送方:包括这个帧,以及这个帧以前的所有帧,它都已经全部正确接收了。

      3. 超时事件

        协议的名字为后退N帧/回退N帧,来源于出现丢失和时延过长帧时发送方的行为。就像在停等协议中一样,定时器将再次用于恢复数据帧或确认帧的丢失。如果出现超时,发送方重传所有已发送但未被确认的帧。

    2. GBN接收方要做的事

      如果正确收到n号帧,并且按序,那么接收方为n帧发送一个ACK,并将该帧中的数据部分交付给上层。

      其余情况都丢弃帧,并为最近按序接收的帧重新发送ACK。接收方无需缓存任何失序帧,只需要维护一个信息: expectedseqnum(下一个按序接收的帧序号)。

      即:接收方很专一,如果没有接收到对应帧的到来,后面的帧即使到了也会被丢弃

    3. 示意图

      在这里插入图片描述

  3. 滑动窗口长度可以无限长吗?

    若采用n个比特对帧编号,那么发送窗口的尺寸WT,应满足:1 <= W <= 2^n-1。因为发送窗口尺寸过大,就会使得接收方无法区别新帧和旧帧(新帧与旧帧的帧编号相同)。

  4. GBN协议重点总结

    1. 累计确认(偶尔捎带确认,接收方把确认帧放在了接收方要发给发送方的数据里)
    2. 接收方只按顺序接收帧,不按序无情丢弃
    3. 确认序列号最大的,按序到达的帧
    4. 发送窗口最大为2^n-1,接收窗口大小为1

    例题:

    image-20210409215055723

  5. GBN协议性能分析

    1. 优点:因连续发送数据帧而提高了信道利用率

    2. 缺点:在重传时,必须把原来已经正确传送的数据帧重传,使得传送效率降低

      选择重传协议可以解决这个缺点

脑图:

image-20210409215131878

3.1.4.3 、选择重传协议(SR)

GBN协议的弊端:累计确认—>批量重传。

可不可以只重传出错的帧?

解决办法:设置单个确认,同时加大接收窗口,设置接收缓存,缓存乱序到达的帧。

  1. 选择重传协议中的滑动窗口示意图

    在这里插入图片描述

  2. SR发送方必须响应的三件事

    1. 上层的调用

      从上层收到数据后,SR发送方检查下一个可用于该帧的序号,如果序号位于发送窗口内,则发送数据帧;

      否则就像GBN一样,要么将数据缓存,要么返回给上层之后再传输。

    2. 收到了一个ACK

      如果收到ACK,加入该帧序号在窗口内,则SR发送方将那个被确认的帧标记为已接收。

      如果该帧序号是窗口的下界(最左边第一个窗口对应的序号),则窗口向前移动到具有最小序号的未确认帧处。

      如果窗口移动了并且有序号在窗白内的未发送帧,则发送这些帧。

    3. 超时事件

      每一个帧都有自己的定时器,一个超时事件发生后只重传一个帧

      哪个帧的超时器超时,则重传哪个帧

  3. SR接收方要做的事情

    1. 窗口内的帧来者不拒

    2. SR接收方将确认一个正确接收的帧不管其是否按序

      失序的帧将被缓存,并返回给发送方一个该帧的确认帧【收谁就确认谁】,直到失序前面所有帧(即序号更小的帧)皆被接收到为止,这时才可以将一批帧按序交付给上层,然后向前滑动窗口

    3. 如果收到了窗口序号外(小于窗口下界)的帧,就返回一个ACK

    4. 其他情况就忽略该帧

  4. SR协议运行过程示意图

    在这里插入图片描述

  5. 滑动窗口长度可以无限长吗?

    1. 发送窗口大小最好等于接收窗口(大了会溢出,小了没意义)

      image-20210409223428612

    2. WTmax=WRmax=2^(n-1)

      image-20210409223645651

  6. SR协议重点总结

    1. 对数据帧逐一确认,收一个确认一个
    2. 只重传出错帧
    3. 接收方有缓存
    4. WTmax=WRmax=2^(n-1)

例题:

image-20210409223859479

脑图:

image-20210409223919103

3.2.1 、信道划分介质访问控制

  1. 传输数据使用的两种链路

    1. 点对点链路

      两个相邻节点通过一个链路相连,没有第三者。

      应用:PPP协议常用于广域网

    2. 广播式链路

      所有主机共享通信介质。

      应用:早期的总线以太网、无线局域网,常用于局域网

      典型拓扑结构:总线型、星型(逻辑总线型)

  2. 介质访问控制

    介质访问控制的内容就是,采取一定的措施,使得两对界限之间的通信不会发生相互干扰的情况

    介质访问控制分类

    1. 静态划分信道,即信道划分介质访问控制(C!WTF)

      1. 频分多路复用FDM(frequency)
      2. 时分多路复用TDM(time)
      3. 波分多路复用WDM(wave)
      4. 码分多路复用CDM(code)
    2. 动态分配信道

      1. 轮询访问介质访问控制

        令牌传递协议

      2. 随机访问介质访问控制

        1. ALOHA协议
        2. CSMA协议
        3. CSMA/CD协议(重要)
        4. CSMA/CA协议(重要)
  3. 信道划分介质访问控制

    将使用介质的每个设备与来自同一信道上的其他设备的通信隔离开,把时域和频域资源合理地分配给网络上的设备

    1. 多路复用技术

      把多个信号组合放在一条物理信道上进行传输,使得多个计算机或终端设备共享信道资源,提高信道利用率

      把一条广播信道,逻辑上分成几条用于两个节点之间通信的互不干扰的子信道,实际就是把广播信道转变为点对点信道

      图示:

      在这里插入图片描述

    2. 静态划分信道(信道划分介质访问控制)

      1. 频分多路复用FDM

        1. 概念:

          用户在分配到一定的频带后,在通信过程中自始至终都占用这个频带。

          频分复用的所有用户在同样的时间占用不同的带宽(频率带宽)资源。

          image-20210410023214368

        2. 优点:充分利用传输介质带宽,系统效率更高;由于技术比较成熟,实现也比较容易。

      2. 时分多路复用TDM

        1. 概念:

          将时间划分为一段段等长的时分复用帧(TDM帧)。

          每一个时分复用的用户在每一个TDM帧中占用固定序号的时隙,所有用户轮流占用信道。

          TDM帧与数据链路层的帧不同,TDM帧是在物理层传送的比特流所划分的帧,标志一个周期(cpu的时间片轮转)。

        这一个周期对应的是在一个周期内可以发送多少个比特。

        1. 频分复用——“并行”

          时分复用——“并发”

        2. 改进的时分复用——统计时分复用STDM(增加了信道的利用率)

          在这里插入图片描述

      3. 波分多路复用WDM

        概念:波分多路复用就是光的频分多路复用,在一根光纤中传输多种不同波长(频率)的光信号,由于波长(频率)不同,所以各路光信号互不干扰,最后再用波长分解复用器将各路波长分解出来。

        image-20210410023757326

      4. 码分多路复用CDM

        注意:码分多址(CDMA)是码分复用的一种方式,注意与码分多路复用区分

        1. 概念:
          把1个比特分为多个码片/芯片(chip),每一个站点被指定一个唯一的m位(m位通常是128位或64位)的芯片序列发送1时,站点发送送芯片序列,发送0时发送芯片序列的反码(在芯片序列中,把0写成-1,正交的码片,CDM原理是利用向量正交为0)

        2. 如何不打架:多个站点同时发送数据的时候,要求各个站点芯片序列相互正交规格内积化是0

          规格内积化:将对应的各位相乘,然后相加,最后在除于总的位数。

        3. 如何合并:各路数据在信道中被线性相加(对应的各个位进行相加)

        4. 如何分离:合并数据和原站(芯片序列 )规格化内积

        image-20210410024814953

3.2.2 、随机访问介质访问控制

动态分配信道,也叫动态媒体接入控制/多点接入

特点:信道并非在用户通信时固定分配给用户。

随机访问介质访问控制:所有用户可以随机发送信息,发送信息时占全部带宽。(不协调 =》冲突 =》 协议解决)

  • ALOHA协议 不听就说
  • CSMA协议 先听再说
  • CSMA/CD协议(重要) 先听再说,边听边说
  • CSMA/CA协议(重要)

1、ALOHA协议

ALOHA协议(非重点)

  1. 纯ALOHA协议

    1. 思想:不监听信道,不按时间槽发送,随机重发。(想发就发)

      image-20210410025200886

      其中T0规定的是一个数据帧的长度。(一般一个数据帧的长度都是用比特来衡量,这里用T0衡量是什么意思呢?)T0指的是这样一个数据帧的发送时间。这里面的发送时间既包括传输时间,也包括传播时间。也就是一个数据帧从刚开始发送到发送成功为止的这样一段时间就叫做T0。

    2. 冲突如何检测?

      如果发生冲突,接收方就会检测出差错,然后发送否定确认帧或者不发送确认帧,发送方在一定时间内收不到确认帧就判断冲突。

    3. 冲突如何解决?

      超时后等一随机时间再重传。

  2. 时隙ALOHA协议

    思想:把时间分成若干个相同的时间片(T0,也可以叫做时间槽),所有用户的时间片开始时刻同步接入网络信道,若发生冲突,则必须等到下一个时间片开始时刻再发送。(控制想发就发的随意性)

    主要特点:

    • 每一个站点在发送帧的时候,只能在一个时间片/时间槽的开始来发送
    • 若站点当前想要发送数据帧,但是还没到一个时间片的开始,那么站点就会等待一个时间片的到来之后在进行发送
    • 如果数据帧在发送过程中发生碰撞,那么这个结点就会在时隙结束之后,也就是经过一个T0之后,发送方发现了这样一个碰撞(接收方没有返回一个确认帧),发送方就判定数据在发送过程中发生了冲突。于是发送方进行超时重传。
    • 发送方进行超时重传是依旧遵循之前的协议。在一个时隙(时间片)开始的时候来重传数据帧

    image-20210410030015295

  3. 关于ALOHA协要知道的事

    1. 纯ALOHA比时隙ALOHA吞吐量更低,效率更低
    2. 纯ALOHA想发就发,时隙ALOHA只有在时间片段开始时才能发
    3. 不冲突概率=p(1-p)^2(N-1) = 1/2e,而时隙ALOHA只考虑一个时隙开始时,所以时隙ALOHA的效率是纯ALOHA效率的两倍

2、CSMA协议

  1. 名词详解

    载波监听多路访问协议CSMA(carrier sense multiple access)

    • CS:载波侦听/监听,每一个站在发送数据之前要检测一下总线上是否有其他计算机在发送数据。

      • 如何监听?

        当几个站同时在总线上发送数据时,总线上的信号电压摆动值将会增大(互相叠加)。当一个站检测到的信号电压摆动值超过一定门限值时,就认为总线上至少有两个站同时在发送数据,表明产生了碰撞,即发生了冲突。

    • MA:多点接入,表示许多计算以多点接入的方式连接在一根总线上

  2. 协议思想:发送帧之前,监听信道

    监听结果:

    1. 信道空闲:发送完整数据帧
    2. 信道忙:推迟发送
      • 1-坚持CSMA
      • 非坚持CSMA
      • P坚持CSMA
  3. 1-坚持CSMA

    • 坚持是指:对于监听信道之后的坚持。

    • 1-坚持CSMA思想:

      如果一个主机要发送信息,那么它先监听信道。

      • 监听结果空闲,则不必等待直接发送
      • 监听结果为忙,则一直监听,直到空闲马上传输
      • 如果有冲突(一段时间内未收到肯定回复),则等待一个随机长的时间再监听(等待随机长的时间这一点与ALOHA协议类似,后同),重复上述过程。
    • 优点:只要媒体空闲,站点就马上发送,避免了媒体利用率的丢失

    • 缺点:假如有两个或两个以上的站点有数据要发送,冲突就不可避免

      比如这多个站点全部采用1-坚持CSMA,则一检测到信道空闲,就会同时发送信息,就会发生冲突。

  4. 非坚持CSMA

    1. 非坚持CSMA思想:

      如果一个主机要发送信息,那么它先监听信道。

      • 监听结果空闲,则不必等待直接发送
      • 监听结果为忙,则等待以后随机时间之后再进行监听
    2. 优点:采用随机的重发延迟时间,可以减少冲突发生的可能性

    3. 缺点:可能存在大家都在延迟等待过程中,使得媒体仍可能处于空闲状态,媒体使用率低。

  5. P-坚持CSMA

    1. P-坚持是指:对于监听信道空闲的处理。

    2. P-坚持CSMA的思想

      如果一个主机要发送信息,那么它先监听信道。

      • 空闲则以p概率直接传输,不必等待;概率1-p等待到下一个时间槽再传输。
      • 忙则等待下一个时隙开始才监听,故叫做持续监听,重复上述过程
    3. 优点:既能像非坚持算法那样减少冲突,又能像1-坚持算法那样减少媒体空闲时间。

    4. 缺点:发生冲突后还是要坚持把数据帧发送完,造成了浪费(这是所有CSMA的缺点,1-坚持、非坚持、P-坚持CSMA都有的缺点)

    三种CSMA的对比(注意P-坚持CSMA不太一样):

    image-20210410032318214

3、CSMA/CD协议(重要)

  1. 大体思想:边发送数据,边监听信道,如果发生冲突就停止发送数据

  2. 名词详解

    载波监听多点接入/CD(也叫碰撞检测CSMA) (carrier sense multiple access with collision detection)

    CS:载波侦听/监听,每一个站点在发送数据之前以及发送数据时都要检测一下总线上是否有其他计算机在发送数据。

    与CSMA不同的是:CSMA/CD在发送数据时也会监听信道

    MA:多点接入,表示许多计算机以多点接入的方式连接在一根总线上。=》 总线型网络

    CD:碰撞检测(冲突检测),“边发送边监听”,应用于适配器边发送数据,边检测信道上信号电压的变化情况,以便判断自己在发送数据时,其他站是否也在发送数据。

    应用于:半双工网络

    主要应用于总线式以太网

  3. 为什么先监听后发送还会产生冲突?

    因为:电磁波在总线上总是以有限的速率传播的。

    传播时延对载波监听的影响:

    image-20210410032912095

    假设:单程端到端传播时延:t 最迟多久才能知道自己发送的数据没和别人碰撞?

    最多是两倍的总线到端的传播时延(2 * t),即总线的端到端的往返传播时延(2 * t)

    只要经过2 * t时间还没有检测到碰撞,就能肯定这次发送不会发生碰撞。

    image-20210410033340427

  4. 如何确定碰撞后的重传时机?

    如果检测到碰撞立即重发会导致恶性循环:

    image-20210410033509456

    截断二进制指数规避算法

    1. 确定基本退避(推迟)时间为争用期2t

    2. 定义参数k,它等于重传次数,但k不超过10,即k=min[重传次数,10]。

      • 当重传次数不超过10时,k等于重传次数;
      • 当重传次数大于10时,k就不再增大而一直等于10。
    3. 从离散的整数集合[0,1,…,2^k-1]中随机取出一个数r,重传所需要退避的时间就是r倍的基本退避时间,即2 * r * t。

    4. 当重传达16次(最大重传次数)仍不能成功时,说明网络太拥挤,认为此帧永远无法正确发出,抛弃此帧并向高层报告出错。

      截断二进制指数规避算法使用示例

      在这里插入图片描述

    例题:

    image-20210410034142801

  5. 最小帧长问题:

    A站发了一个很短的帧,但是发生了碰撞,不过帧在发送完毕后才检测出发生碰撞,没法停止发送。为了使CSMA/CD协议有意义,要定义一个最小帧长。

    帧的传输时延至少要两倍于信号在总线中的传播时延

    帧的传输时延 = 帧长(bit)/ 数据传输率 >= 2 * 总线传播时延

    即:最小帧长 = 2 * 总线传播时延 * 数据传输速率 = 2 * t * 数据传输速率

    补充:以太网规定最短帧长为64B,凡是长度小于64B的都是由于冲突而异常终止的无效帧。因此,以太网为了达到这个最小帧长,对于一个比较短的帧,它会对它进行一个填充操作,使它的帧长大于等于64B,然后才能将它放到链路上进行发送。

    脑图:

    image-20210410034655978

4、CSMA/CA协议(重要)

CA:对碰撞的避免

CD:对碰撞的检测

  1. 名词解释

    1. 载波监听多点接入/CA(碰撞避免CSMA,不能检测碰撞)(carrier sense multiple access with collision avoidance)

    2. 为什么要有CSMA/CA?

      主要是因为:CA主要应用于无线局域网

      1. 在无线局域网中无法使用CD协议,不能做到360度全面检测碰撞

        • CD主要应用于总线式以太网
      2. 隐蔽站问题,当A和C都检测不到信号,认为信道空闲时,同时向终端B发送数据帧,就会发生冲突。

        C相对于A就是隐蔽站

    3. 有礼貌的CSMA/CA:不光是先听后发,在听了之后,发送数据之前会等一小段时间。

  2. CSMA/CA工作原理

    发送数据之前,先检测信道是否空闲。

    若空闲则发出RTS(request to send),RTS包括发射端的地址、接收端的地址、下一份数据将持续发送的时间等信息;RTS可发可不发,发RTS是为了解决隐蔽站的问题

    若信道忙,则等待。接收端收到RTS后,将响应CTS(clear to send)

    RTS和CTS就是用来解决隐蔽站的问题:

    • 发送端收到CTS后,开始发送数据帧(同时开始预约信道:发送方告知其他站点自己要传多久数据)

    • 接收端收到数据帧后,将用CRC(CRC循环冗余检验)来检验数据是否正确,正确则响应ACK帧

    • 发送方收到ACK就可以进行下一个数据帧的发送,若没有则一直重传至规定重发次数为止

      (这里跟CD协议一样,采用二进制指数退避算法来确定随机的推迟时间。)

  3. 三个机制实现碰撞避免

    1. 预约信道
    2. ACK帧
    3. RTS/CTS帧(可选,主要是解决隐蔽站的问题)
  4. CD和CA协议的比较

    • 相同点:

      CD和CA机制都从属于CDMA的思路,其核心就是先听再说

      换言之,两个在接入信道前都要进行监听。当发现信道空闲后,才能进行接入。

    • 不同点:

      1. 传输介质不同
        • CD用于总线式以太网【有线】
        • CA用于无线局域网【无线】
      2. 载波检测方式不同
        • 应传输介质不同,CD和CA的检测方式也不同。
        • CD通过电缆中电压的变化来检测,当数据发生碰撞时,电缆中的电压就会随着发生变化;
        • CA采用能量检测(ED)、载波检测(CS)和能量载波检测三种检测信道空闲的方式。
        • CSMA/CD检测冲突CSMA/CA避免冲突,两者出现冲突后都会进行有上限的重传。

3.2.3 、轮询访问介质访问控制

信道划分介质访问控制(MAC Multiple Access Control)协议:

  • 基于多路复用技术划分资源
  • 网络负载重时,共享信道效率高,且公平
  • 网络负载轻时:共享信道效率低

随机访问MAC协议:

  • 用户根据意愿随机发送信息,发送信息时可独占信道带宽
  • 网络负载重时,产生冲突开销
  • 网络负载轻时,共享信道效率高,单个结点可利用信道全部带宽

轮询访问MAC协议/轮流协议/轮转访问MAC协议:

综合信道划分介质访问控制协议和随机访问MAC协议,既不产生冲突,也要发送时占用全部带宽

  • 轮询协议

    主结点轮流“邀请”从属结点发送数据

    image-20210504145458454

    问题:

    1. 轮询开销
    2. 靠后结点有等待延迟
    3. 单点故障:主结点发生故障
  • 令牌传递协议(重要)

    image-20210504145553598

主机

TCU(转发器)

令牌:一个特殊格式的MAC控制帧,不含任何信息

控制信道的使用,确保同一时刻只有一个结点独占信道。

每一个结点都可以在一定的时间内(令牌持有时间内)获得发送数据的权利,并不是无限制地持有令牌

问题:

  1. 令牌开销
  2. 等待延迟
  3. 单点故障(一个主机宕机后,线路故障)

通常应用于令牌环网(物理星型拓扑,逻辑环形拓扑)

采用令牌传送方式的网络常用于负载较重、通信量较大的网络中。

介质访问控制总结:

image-20210504150943372

3.3.1 、局域网基本概念和体系结构

局域网(LAN,Local Area Network)

  1. 概念:是指某一区域内由多台计算机互连成的计算机组,使用广播信道

  2. 特点

    1. 覆盖地理范围小,只在一个相对独立的局部范围内联,如一座或集中的建筑群内。
    2. 使用专门铺设的传输介质(双绞线、同轴电缆)进行联网,数据传输速率高(10Mb/s-10Gb/s)
    3. 通信延迟时间短,误码率低,可靠性高
    4. 各站点为平等关系,共享传输信道
    5. 多采用分布式控制和广播式通信,能进行广播和组播
  3. 决定局域网的主要要素为:网络拓扑传播介质介质访问控制方法

    1. 局域网的网络拓扑

      1. 星型拓扑

        中心节点是控制中心,任意两个节点间的通信最多只需两步,传输速度快,并且网络构形简单、建网容易、便于控制和管理。但这种网络系统,网络可靠性低,网络共享能力差,有单点故障问题

      2. 总线型拓扑(常用)

        网络可靠性高、网络节点间响应速度快、共享资源能力强、设备投入量少、成本低、安装使用方便,当某个工作站节点出现故障时,对整个网络系统影响小。

      3. 环形拓扑

        系统中通信设备和线路比较节省。有单点故障问题;由于环路是封闭的,所以不便于扩充,系统响应延时长,且信息传输效率相对较低。

      4. 树形拓扑

        易于拓展,易于隔离故障,也容易有单点故障。

    2. 局域网传输介质

      1. 有线局域网 常用介质:双绞线、同轴电缆、光纤
      2. 无线局域网 常用介质:电磁波
    3. 局域网介质访问控制方法

      1. CSMA/CD 常用于总线型局域网,也用于树型网络

      2. 令牌总线常用于总线型局域网,也用于树型网络

        它是把总线型或树型网络中的各个工作站按一定顺序如按接口地址大小排列形成一个逻辑环。只有令牌持有者才能控制总线,才有发送信息的权力。

      3. 令牌环 用于环形局域网,如令牌环网

        • 逻辑拓扑:环型(逻辑拓扑主要受通信思想的制约)
        • 物理拓扑:星型(物理拓扑主要受限制的制约)
  4. 局域网的分类

    1. 以太网

      以太网是应用最广泛的局域网,包括标准以太网(10Mbps)、快速以太网(100Mbps)、千兆以太网(1000Mbps)和10G以太网,它们都符合IEEE 802.3系列标准规范。

      逻辑拓扑总线型,物理拓扑是星型或拓展星型。使用CSMA/CD

    2. 令牌环网

      造价高,不是很实用,已是明日黄花

      物理拓扑星型,逻辑拓扑环型

    3. FDDI网(Fiber Distributed Data Interface)(了解)

      用的很少

      物理双环拓扑,逻辑环型拓扑

    4. ATM网(Asynchronous Transfer Mode)(了解)

      较新型的单元交换技术,使用53字节固定长度的单元进行交换

    5. 无线局域网(Wireless Local Area Network,WLAN)

      采用IEEE 802.11标准

  5. IEEE 802标准

    IEEE802系列标准是IEEE802LAN/MAN标准委员会制定的局域网、城域网技术标准(1980年2月成立)其中最广泛使用的有以太网、令牌环网、无线局域网。这一系列标准中的每一个子标准都由委员会中的一个专门工作组负责。

    1. IEEE802.3标准

      以太网介质访问控制协议及物理层技术规范

    2. IEEE802.5标准

      令牌环网的介质访问控制协议及物理层技术规范

    3. IEEE802.8标准

      光纤技术咨询组,提供有关光纤联网的技术咨询(FDDI网

    4. IEEE802.11

      无线局域网(WLAN)的介质访问控制协议及物理层技术规范

  6. MAC子层和LLC子层

    IEEE802标准所描述的局域网参考模型只对应OSI参考模型的数据链路层和物理层,它将数据链路层划分为逻辑链路层LLC子层介质访问控制MAC子层

    1. LLC负责识别网络层协议,然后对它们进行封装。LLC报头告诉数据链路层一旦帧被接收到时,应当对数据包做何处理。

      为网络层提供服务:无确认无连接、面向连接、带确认无连接、高速传送。

    2. MAC子层的主要功能包括数据帧的封装/卸装,帧的寻址和识别,帧的接收与发送,链路的管理,帧的差错控制等。

      MAC子层的存在屏蔽了不同物理链路种类的差异性。

    image-20210504150742911

脑图

image-20210504150832973

3.3.2、以太网概述

  1. 概念

    以太网(Ethernet)指的是由Xerox公司创建并由Xerox、Intel和DEC公司联合开发的基带总线局域网规范,是当今现有局域网采用的最通用的通信协议标准。以太网络使用CSMA/CD(载波监听多路访问及冲突检测)技术

  2. 以太网在局域网各种技术中占统治地位

    1. 造价低廉(以太网网卡不到100块)
    2. 是应用最广泛的局域网技术
    3. 比令牌环网、ATM网便宜,简单
    4. 满足网络速率的要求,10Mbps-10Gbps
  3. 以太网的两个标准

    1. DIX Ethernet V2:第一个局域网产品(以太网)规约。
    2. IEEE802.3:IEEE802委员会802.3工作组制定的第一个IEEE的以太网标准

    这两个标准的区别不大,只是在帧的格式上有两个字节的差异,
    因此只要满足两个标准中的一个都叫以太网,以太网也叫802.3局域网

  4. 以太网提供无连接、不可靠的服务

    • 无连接:发送方和接收方之间无“握手过程”。
    • 不可靠:
      • 不对发送方的数据帧编号,
      • 接收方不向发送方进行确认,
      • 差错帧直接丢弃,
      • 差错纠正由高层负责。

    以太网只实现无差错接受,不实现可靠传输

  5. 以太网传输介质和拓扑结构的发展

    • 传输介质:粗同轴电缆–>细同轴电缆–>双绞线+集线器
    • 物理拓扑:总线型–>星型
      • 使用集线器的以太网在逻辑上仍是一个总线网,各站共享逻辑上的总线,使用的还是CSMA/CD协议
    • 以太网拓扑:逻辑上总线型,物理上星型
  6. 10BASE-T以太网

    • 10BASE-T是传送基带信号的双绞线以太网,T表示采用双绞线,现10BASE-T采用的是无屏蔽双绞线(UTP),传输速率是10Mb/s
    • 物理上采用星型拓扑、逻辑上总线型,每段双绞线最长100m
    • 采用曼彻斯特编码
    • 采用CSMA/CD介质访问控制
  7. 适配器与MAC地址

    • 计算机与外界有局域网的连接是通过通信适配器的。
      • 网络接口板
      • 网络接口卡NIC(network interface card),现在不再使用网卡
      • 适配器上装有处理器和存储器(包括RAM和ROM)
      • ROM上有计算机硬件地址MAC地址
    • 在局域网中,硬件地址又称为物理地址,或MAC地址。【实际上是标识符
    • MAC地址:每个适配器由全球唯一的二进制地址,前24位代表厂家(有IEEE规定),后24位厂家自己指定。常用6个十六进制数表示。即:这个是12个16进制数决定,前六位是厂家,后六位是各个网络制造商自己规定的。如02-60-8c-e4-b1-21
  8. 以太网MAC帧

    • 最常用的MAC帧是以太网V2的格式

      img

      • 目的地址有三种情况
        • 单播地址,一个专有的MAC地址。传播给固定主机
        • 广播地址:8B的前导码全”1”(二进制),或者全”F”(十六进制)。会发生给所有主机
        • 多播地址
    • 与IEE 802.3的区别:

      1. 第三个字段是长度/类型
      2. 当长度/类型字段值小于0x0600时,数据字段必须装入LLC子层。
  9. 高速以太网

    1. 100BASE-T以太网

      • 双绞线上传送100Mb/s基带信号星型拓扑以太网,仍使用IEEE802.3的CSMA/CD协议。

      • 支持全双工和半双工,可在全双工方式下工作而无冲突(不使用CSMA/CD协议)。

        • 全双工(交换机可以隔离冲突域,每一个交换机的端口都是一个冲突域,一个主机在一个冲突域当中不存在冲突)

          image-20210504153357428

    2. 吉比特以太网

      • 光纤或双绞线上传送1Gb/s信号
      • 支持全双工和半双工,可在全双工方式下工作而无冲突
    3. 10吉比特

      • 10吉比特以太网在光纤上传送10Gb/s信号
      • 只支持全双工,无争用问题

脑图

image-20210504153615323

3.3.3、无线局域网

IEEE802.11是无线局与通信用的标准,它是由IEEE所定义的无线通信的标准

wifi是WLAN的一种应用,WLAN可以比较大。

802.11的MAC帧头格式

image-20210504153957809

image-20210504154046849

总结一下:

  • IBSS就是一个服务集内的移动站点不通过基站的直接通信
  • To AP 就是服务集内的移动站点向基站的通信
  • From AP 就是服务集内基站向移动站的通信
  • WDS就是不同服务集内的两个移动站之间的通信(漫游)

无线局域网的分类:

  1. 有固定基础设施无线局域网

    image-20210504154529596

  2. 无固定基础设施无线局域网的自组织网络

    image-20210504154658400

3.3.4、PPP协议和HDLC协议

广域网(WAN,Wide Area Network)通常跨接很大的物理范围,所覆盖的范围从几十公里到几千公里,它能连接多个城市或国家,或横跨几个洲并能提供远距离通信,形成国际性的远程网络。

广域网的通信子网主要使用分组交换技术。广域网的通信子网可以利用公用分组交换网、卫星通信网和无线分组交换网,它将分布在不同地区的局域网或计算机系统互连起来,达到资源共享的目的。如因特网(Internet)是世界范围内最大的广域网。

广域网强调资源共享,局域网强调数据传输

image-20210504154840374

  1. PPP协议:

    1. 点对点协议PPP(Point-to-Point Protocol)是目前使用最广泛的数据链路层协议,用户使用拨号电话接入因特网时一般都是用PPP协议

      • 只支持全双工链路
    2. PPP协议应满足的要求:

      • 简单 对于链路层的帧,无需纠错,无需序号,无需流量传输
      • 封装成帧 帧定界符
      • 透明传输 与帧定界符一样比特组合的数据应该如何处理:异步线路用字节填充同步线路用比特填充
      • 多种网络层协议 封装的IP数据报可以采用多种协议
      • 多种类型链路 串行/并行,异步/同步,光/电
      • 差错检测 错就丢弃
      • 检测连接状态 链路是否正常
      • 最大传送单元 数据部分最大长度MTU(默认不超过1500B)
      • 网络层地址协商 知道通信双方的网络层地址
      • 数据压缩协商
    3. PPP协议无需满足的要求:(纠流编多)

      • 纠错
      • 流量控制
      • 对帧编序号
      • 不支持多点线路
    4. PPP协议的三个组成部分:

      1. 一个将IP数据报封装到串行链路(同步串行/异步串行)的方法
      2. 链路控制协议LCP:建立并维护数据链路连接。(物理连接)
        • 应用:身份验证
      3. 网络控制协议NCP:PPP可支持多种网络层协议,每个不同的网络层协议都要一个相应的NCP来配置,为网络层协议建立和配置逻辑连接(逻辑连接)
    5. PPP协议的状态图

      img

    6. PPP协议的帧格式

      • 帧格式是什么东西?

      • 还有MAC帧格式(以字节为单位)(7E\7D)

        img

  2. HDLC协议

    高级数据链路控制(High-Level Data Link Control或简称HDLC),是一个在同步网上传输数据、面向比特的数据链路层协议,它是由国际标准化组织(ISO)根据IBM公司的SDLC(SynchronousData Link Control)协议扩展开发而成的。

    数据报文可透明传输,用于实现透明传输的“0比特插入法”易于硬件实现

    采用全双工通信

    所有帧采用CRC检验,对信息帧进行顺序编号可防止漏收或重份,传输可靠性高

    HDLC的站:主站从站复合站

    1. 主站的主要功能是发送命令(包括数据信息)帧、接收响应帧,并负责对整个链路的控制系统的初启、流程的控制、差错检测或恢复等。
    2. 从站的主要功能是接收由主站发来的命令帧,向主站发送响应帧,并且配合主站参与差错恢复等链路控制。
    3. 复合站的主要功能是既能发送,又能接收命令帧和响应帧,并且负责整个链路的控制。

    HDLC的三种数据操作方式:

    1. 正常响应方式

      从站发送消息要经过主站的同意,主站命令从站发送数据,从站才可以发送数据

    2. 异步平衡方式

      每一个复合站都可以对其他站的数据传输,每个站都是平等的地位

    3. 异步响应方式

      从站可以不经过主站的同意就进行数据的传输

    HDLC的帧格式:

    image-20210504160825465

    对于地址A:取决于当前选择的数据操作方式

    • 正常响应方式/异步响应方式:从站的地址
    • 异步平衡方式:对应站(应答站),也就是对方的地址

    对于控制C:决定了HDLC帧的类型(无奸细)

    • 信息帧(I)**:第1位为0,用来传输数据信息,或使用捎带技术对数据进行确认**;
    • 监督帧(S)**:10**, 用于流量控制和差错控制,执行对信息帧的确认、请求重发和请求暂停发送等功能
    • 无编号帧(U)**:11**, 用于提供对链路的建立、拆除等多种控制功能。
  3. PPP协议 & HDLC协议

    相同点:

    • HDLC、PPP只支持全双工链路
    • 都可以实现差错检测,但不纠正差错
    • 都可以实现透明传输
      • 关于透明传输的一点小差别:
        • PPP协议既可以实现0比特填充的比特型的填充方法,也可以实现字节填充的方法
        • HDLC协议只能实现0比特填充的比特型的填充方法

    区别:

    PPP协议 面向字节 2B协议字段 无序号和确认机制 不可靠
    HDLC协议 面向比特 没有 有编号和确认机制 可靠

image-20210504162250883

脑图

image-20210504162337180

3.4.1、链路层设备

3.4.1.1、物理层扩展以太网

采用光纤的方式

image-20210504162926611

采用集线器的方式

image-20210504163113104

可以扩展以太网,但是集线器会无脑将一个设备的所有消息转发到集线器所连的所有设备,故会将所连接的所有设备变成一个大的冲突域,同时只能有两台设备进行通信,且设备越多,冲突越多。由此诞生了网桥

3.4.1.2、数据链路层扩展以太网

采用网桥的方式:

网桥根据MAC帧的目的地址对帧进行转发过滤。当网桥收到一个帧时,并不向所有接口转发此帧,而是先检查此帧的目的MAC地址,然后再确定将该帧转发到哪一个接口,或者是把它丢弃(即过滤)。

image-20210504163354054

网桥优点:

  • 过滤通信量,增大吞吐量。
  • 扩大了物理范围。
  • 提高了可靠性。
  • 可互连不同物理层、不同MAC子层和不同速率的以太网。

网桥的分类:

  • 透明网桥:“透明”指以太网上的站点并不知道所发送的帧将经过哪几个网桥,是一种即插即用设备——自学习。

    • 关于自学习

      image-20210504164127428

  • 源路由网桥:在发送帧时,把详细的最佳路由信息( 路由最少/时间最短)放在帧的首部中。

    方法:源站以广播方式向欲通信的目的站发送一个发现帧

    image-20210504164759387

采用交换机的方法

image-20210504165249409

以太网交换机的两种交换方式:

  • 直通式交换机:查完目的地址(6B) 就立刻转发。
    • 优点:延迟小
    • 缺点:可靠性低,无法支持具有不同速率的端口的交换。
  • 存储转发式交换机(常用):将帧放入高速缓存,并检查否正确,正确则转发,错误则丢弃。
    • 优点:可靠性高,可以支持具有不同速率的端口的交换
    • 缺点:延迟大

冲突域 VS 广播域

  • 冲突域:在同一个冲突域中的每一个节点都能收到所有被发送的帧。简单的说就是同一时间内只能有一台设备发送信息的范围
  • 广播域:网络中能接收任一设备发出的广播帧的所有设备的集合。简单的说如果站点发出一个广播信号,所有能接收收到这个信号的设备范围称为一个广播域
能否隔离冲突域 能否隔离广播域
物理层设备[傻瓜]
(中继器、集线器)
× ×
链路层设备[路人]
(网桥、交换机)
×
网络层设备[大佬]
(路由器)

相关例题:(广播域看路由器,冲突域:交换机每一个接口就是一个冲突域)

image-20210504170220974

脑图:

image-20210504170343115

3.5、第三章总结

image-20210504170510895

第四章 网络层

img

img

4.1、网络层的任务与功能

image-20210505155309690

4.2、数据交换方式

1、网络的“掌中宝”——路由器

image-20210505155454971

2、为什么要数据交换

image-20210505155727387

3、数据交换的方式

数据交换可分为三种方式:

  • 电路交换
  • 报文交换
  • 分组交换
    • 数据报方式
    • 虚电路方式

4、电路交换

image-20210505160852979

5、报文交换

image-20210517160352740

6、分组交换

image-20210505161751869

7、报文交换 & 分组交换

image-20210505162605543

8、三种数据交换方式比较总结

image-20210505162837182

9、数据报方式&虚电路方式

image-20210505163608737

10、几种传输单元名词辨析

image-20210505164202246

11、数据报(应用于因特网)

image-20210505164423826

12、虚电路

image-20210505164740761

13、数据报 & 虚电路

image-20210505164918003

4.3.1、IP数据报格式

1、TCP/IP协议栈

image-20210505165458233

2、IP数据报格式

image-20210505165620439

image-20210505171044015

image-20210517165201436

4.3.2、IP数据报分片

1、最大传送单元MTU

image-20210505171323581

2、IP数据报格式——分片相关

image-20210505171749113

3、IP数据报分片例题

image-20210505172207007

4、IP数据报格式——相关单位(一种八片首饰)

image-20210505172335655

4.3.3、IPv4地址

1、IP地址

image-20210505172546692

2、IP编址的历史阶段

  • 分类的IP地址
  • 子网的划分
  • 构成超网(无分类编址方法)

3、分类的IP地址

image-20210505172901070

1、互联网中的IP地址:

image-20210505173055123

2、分类的IP地址:

image-20210505173438999

3、特殊IP地址

image-20210505173834400

4、私有IP地址

image-20210505174001052

5、分类IP使用个数

image-20210505192925617

4.3.4、网络地址转换NAT

image-20210505193330114

网络地址转换NAT:

image-20210505193808509

4.3.5、子网划分和子网掩码

1、子网划分

image-20210505194037834

image-20210505194204411

image-20210505194311386

2、子网掩码

子网掩码:是为了区分网段的 掩码和主机号与主机号比较来判断属不属于该网段

image-20210505194758394

相关习题:

习题1:

image-20210505195105282

习题2:

image-20210505200049573

3、使用子网时分组的转发

image-20210505200430899

4.3.6、无分类编址CIDR

image-20210505201051592

image-20210505201235591

构成超网

image-20210505201651932

最长前缀匹配

image-20210505202925728

image-20210505203331296

4.3.7、ARP协议

1、发送数据的过程

IP1向IP3发送数据:

image-20210505203902705

IP1向IP5发送数据:

image-20210505212537717

2、ARP协议

image-20210505212854516

ARP地址的相关习题

image-20210505213228348

4.3.8、DHCP协议

1、主机如何获得IP地址?

image-20210505223656891

2、DHCP协议

image-20210505224535421

4.3.9、ICMP协议

1、ICMP协议作用

为了更有效地转发IP数据报和提高交付成功的机会

2、网际控制报文协议ICMP

image-20210505225626767

3、ICMP差错报告报文(5种)

image-20210505225923078

4、ICMP差错报告报文数据字段

image-20210505230122748

5、不应发送ICMP差错报文的情况

image-20210505230238989

6、ICMP询问报文

image-20210505230910474

7、ICMP的应用

image-20210505231645689

4.4、IPv6

1、为什么有IPv6

image-20210505232136621

2、IPv6数据报格式

image-20210505233127480

image-20210505233806463

3、IPv6 VS IPv4

image-20210505234147607

4、IPv6地址表示形式

image-20210505234335367

5、IPv6基本地址类型

image-20210505234545274

6、IPv6向IPv4过渡的策略

image-20210505234827815

7、脑图

image-20210505234924693

4.5、路由算法及路由协议

1、路由算法

image-20210506000134403

2、路由算法的分类

image-20210506000028662

3、分层次的路由选择协议

image-20210506000422048

image-20210506000456424

4.6.1、RIP协议与距离向量算法

1、RIP协议

image-20210506000832013

2、RIP协议和谁交换?多久交换一次? 交换什么?

image-20210506001325532

3、距离向量算法

image-20210506001614574

相关例题:

image-20210506001913695

image-20210506002345004

4、RIP协议的报文格式

image-20210506002716945

5、RIP协议好消息传得快,坏消息传得慢

image-20210506002829463

image-20210506002927178

image-20210506003058016

image-20210506003123096

image-20210506003157668

脑图

补充下,RIP与距离向量算法不一样:因数据报服务在分组转发时,每个分组独立选择路由转发,从而引出了路由选择协议。RIP叫路由信息协议。为了找出RIP的最短距离引出了距离向量算法。

image-20210506003352720

4.6.2、OSPF协议与链路状态算法

1、OSPF协议

image-20210506003646135

2、链路状态路由算法

image-20210506004006614

3、OSPF的区域

image-20210506004158704

4、OSPF分组

image-20210506004522231

5、OSPF其他特点

image-20210506004650863

4.6.3、BGP协议

1、BGP协议

image-20210506004817609

2、BGP协议交换信息的过程

image-20210506005743562

image-20210506005824214

image-20210506005844401

3、BGP协议报文格式

image-20210506005948843

4、BGP协议特点

image-20210506010034467

5、BGP-4的四种报文

image-20210506010126542

6、三种路由协议比较

image-20210506010314753

image-20210506010353924

4.7、IP组播

1、IP数据报的三种传输方式

image-20210506010805488

image-20210506010910591

image-20210506011014041

2、IP组播地址

image-20210506011327598

3、硬件组播

image-20210506012222229

4、IGMP协议与组播路由选择协议

image-20210506012400642

5、网际组管理协议IGMP

image-20210506012545893

ICMP和IGMP都使用IP数据报传递报文

6、IGMP工作的两个阶段

image-20210506012720613

7、组播路由选择协议

image-20210506012924832

image-20210506013034949

组播路由选择协议常使用的三种算法:

  • 基于链路状态的路由选择
  • 基于距离-向量的路由选择
  • 协议无关的组播(稀疏/密集)

8、脑图

image-20210506013212541

4.8、移动IP

1、移动IP相关术语

image-20210506013719153

image-20210506013851391

2、移动IP通信过程

image-20210506014144890

image-20210506014312720

4.9、网络层设备

1、路由器

image-20210506014602052

2、输入端口对线路上收到的分组的处理

image-20210506014713942

image-20210506014800212

3、三层设备的区别

image-20210506014928456

4、路由表与路由转发

image-20210506015046620

4.10、网络层总结

image-20210506015143791

image-20210506015239776

image-20210506015254927

image-20210506015310030

image-20210506015341546

image-20210506015440181

image-20210506015550548

image-20210506015658610

image-20210506015721592

image-20210506015734462

image-20210506015810700

第五章 传输层

在这里插入图片描述

5.1、传输层概述

1、什么是传输层

image-20210509091650713

2、传输层的两个协议

image-20210509130716955

3、传输层的寻址与端口

image-20210509131021768

image-20210509131114772

5.2、UDP协议

1、用户数据报协议UDP概述

image-20210509131452260

2、UDP首部格式

image-20210509131719115

3、UDP校验

image-20210509131902697

image-20210509132143163

5.3.1、TCP协议特点和TCP报文段格式

1、TCP协议的特点

image-20210509132448448

2、TCP协议的特点 & TCP报文段首部格式

image-20210509133753564

image-20210509132848748

TCP首部——序号:

image-20210509133439721

TCP首部——确认号

image-20210509133333378

相关控制位:

image-20210509135329970

TCP首部控制位——紧急位URG

image-20210509134247734

TCP首部控制位——推送位PSH

image-20210509134713094

TCP首部——窗口

image-20210509135449995

TCP首部——紧急指针

image-20210509135608155

5.3.2、TCP连接管理

1、TCP连接管理

image-20210509135759910

2、TCP的连接建立

image-20210509140112306

3、SYN洪泛攻击

image-20210509143604587

4、TCP的连接释放

image-20210509143655748

image-20210509144000502

5.3.3、TCP可靠传输

1、TCP可靠传输

image-20210509144114911

2、序号

image-20210509144335882

3、确认

image-20210509144702310

4、重传

image-20210509144948012

image-20210509145112616

5.3.4、TCP流量控制

image-20210509145413434

image-20210509150152054

image-20210509150246065

5.3.5、TCP拥塞控制

1、TCP拥塞控制

image-20210509150503776

2、拥塞控制四种算法

image-20210509150710651

3、拥塞控制四种算法——慢开始和拥塞避免

传输轮次:

image-20210509151158840

image-20210509151631221

4、拥塞控制四种算法——快重传和快恢复

image-20210509152156033

5.4、传输层总结

image-20210509152240332

image-20210509152327273

image-20210509152352593

第六章 应用层

img

6.1、网络应用模型

1、应用层概述

image-20210510103902295

2、网络应用模型

1、客户/服务器模型(Client/Server)

image-20210510104239979

2、P2P模型(Peer-to-peer )

image-20210510104646313

6.2、域名解析(DNS)系统

1、DNS系统

image-20210510104903931

image-20210510105032397

2、域名

image-20210510105410771

域名树

image-20210510105508640

3、域名服务器

image-20210510105950375

image-20210510110153218

image-20210510110230680

4、域名解析过程

image-20210510110758844

6.3、文件传输协议FTP

1、文件传送协议

image-20210510111014038

2、FTP服务器和客户端

image-20210510111137982

3、FTP工作原理

image-20210510111724834

image-20210510112854765

image-20210510111833815

6.4、电子邮件

1、电子邮件系统概述——电子邮件的的信息格式

image-20210510114256285

2、电子邮件系统概述——组成结构

image-20210510114344542

image-20210510114417744

3、简单邮件传送协议SMTP

image-20210510114439779

image-20210510114512955

4、MIME

image-20210510114546291

5、邮局协议POP3

image-20210510114622467

6、网际报文存取协议IMAP

image-20210510114808711

7、基于万维网的电子邮件

image-20210510114701163

脑图

image-20210510114720692

6.5、万维网和HTTP协议

1、万维网概述

image-20210510115838121

2、超文本传输协议HTTP

image-20210510115923321

3、HTTP协议的特点

image-20210510120008065

4、HTTP协议的连接方式

image-20210510120102536

5、超文本传输协议HTTP——报文结构

image-20210510123648179

image-20210510123711370

image-20210510123757619

参考链接:

计算机网络(2019 王道考研)

计算机网络思维导图

Vue

Vue官网

一、邂逅Vuejs

1、遇见Vuejs

1、认识Vuejs

  • Vue (读Vue (读音 /vjuː/,类似于 view)音 /vjuː/,类似于 view)

  • Vue是一个渐进式的框架,渐进式的框架:

    • 渐进式意味着你可以将Vue作为你应用的一部分嵌入其中,带来更丰富的交互体验。
    • 如果你希望将更多的业务逻辑使用Vue实现,那么Vue的核心库以及其生态系统。比如Core+Vue-router+Vuex,也可以满足你各种各样的需求。
  • 与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。另一方面,当与现代化的工具链以及各种支持类库结合使用时,Vue 也完全能够为复杂的单页应用提供驱动。

2、Vue的特点和Web开发中常见的高级功能

  • 解耦视图和数据
  • 可复用的组件
  • 前端路由技术
  • 状态管理
  • 虚拟DOM

2、安装Vuejs

安装

1、方式一:下载和引入

官网上直接下载vue.js文件引入到项目(本地)中,其中有开发环境生产环境

注意:

在下载时不能直接点击,直接点击的话你将看到vue.js的源码。应该右键选中从链接另存文件

image-20210320014752045

其中

  • 开发环境用在开发的时候,其中的代码包含了有帮助的命令行警告,方便程序员查看源代码,但相对的文件比较大。

  • 生产环境用在发布产品的时候,其中的代码都是经过压缩的,优化了尺寸和速度,文件也比较小,方便用户下载,但代码的可读性极差。

一句话总结:开发环境面向的是程序员,生产环境面向的是用户。

2、方式二:直接CDN引入

你可以在你的项目中直接CDN(外部)引入:

1
2
3
4
5
6
7
8
9
10
<!-- 开发环境版本,包含了有帮助的命令行警告 -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

<!-- 生产环境版本,优化了尺寸和速度 -->
<script src="https://cdn.jsdelivr.net/npm/vue"></script>

<!--如果你使用原生 ES Modules,这里也有一个兼容 ES Module 的构建文件-->
<script type="module">
import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js'
</script>

3、方式三:NPM安装

在用 Vue 构建大型应用时推荐使用 NPM 安装[1]。NPM 能很好地和诸如 webpackBrowserify 模块打包器配合使用。同时 Vue 也提供配套工具来开发单文件组件

1
2
# 最新稳定版
$ npm install vue

3、第一个Vuejs程序

image-20210320015424885

1、代码的执行

  1. 阅读JavaScript代码,程序发现创建了一个Vue对象;
  2. 创建Vue对象的时候,传入了一些options:{}
    • {}中包含了el属性:该属性决定了这个Vue对象挂载到哪一个元素上,很明显,我们这里是挂载到了id为app的元素上;
    • {}中包含了data属性:该属性中通常会存储一些数据:
      • 这些数据可以是我们直接定义出来的,比如像上面代码这样
      • 也可能是来自网络,从服务器加载的

2、浏览器执行代码的流程

  1. 执行到10~13行代码显然出对应的HTML;
  2. 执行第16行代码创建Vue实例,并且对原HTML进行解析和修改

3、响应式

Vue代码是可以实现响应式的。在浏览器里进入开发者模式F12中的console。在里面修改代码可以实现浏览器的内容也随着修改而响应着改变。

image-20210320020223661

4、Vue与JavaScript (两种编程范式)

  • 命令式编程(JavaScript )

    命令式编程的主要思想是关注计算机执行的步骤,即一步一步告诉计算机先做什么再做什么。

    优点:数据和界面完全分离,不需要js创建页面元素等操作

  • 声明式编程(Vuejs)

    声明式编程是以数据结构的形式来表达程序执行的逻辑。它的主要思想是告诉计算机应该做什么,但不指定具体要怎么做。

    优点:当数据发生改变时界面自动发生改变(响应式)

5、Vue的MVVM

1、是什么MVVM

维基百科官方解释

MVVMModel–view–viewmodel)是一种软件架构模式

MVVM有助于将图形用户界面的开发与业务逻辑后端逻辑(数据模型)的开发分离开来,这是通过置标语言或GUI代码实现的。MVVM的视图模型是一个值转换器,[1] 这意味着视图模型负责从模型中暴露(转换)数据对象,以便轻松管理和呈现对象。在这方面,视图模型比视图做得更多,并且处理大部分视图的显示逻辑。[1] 视图模型可以实现中介者模式,组织对视图所支持的用例集的后端逻辑的访问。

image-20210402232753389

2、Vue的MVVM

image-20210320022023488

  • View层:
    • 视图层
    • 在前端开发中,通常就是DOM层
    • 主要的作用是给用户展示各种信息
  • Model层:
    • 数据层
    • 数据可能是我们固定的死数据,但更多的是来自我们服务器,从网络上请求下来的数据
  • VueModel层:
    • 视图模型层
    • 视图模型层是View和Model沟通的桥梁
    • 一方面它实现了Data Binding,也就是数据绑定,将Model的改变实时的反应到View中
    • 另一方面它实现了DOM Listener,也就是DOM监听,当DOM发生一些事件(点击、滚动、touch等)时,可以监听到,并在需要的情况下改变对应的Data

3、计数器的MVVM示例

计数器:点击 + 计数器+1,点击 - 计数器 -1

在Vue对象中

  • 新属性:methods。该属性用于在Vue对象中定义方法。
  • 新的指令:@click, 该指令用于监听某个元素的点击事件,并且需要指定当发生点击时,执行的方法(方法通常是methods中定义的方法)

image-20210320022244195

image-20210320022417139

计数器中就有严格的MVVM思想:

  • View依然是我们的DOM
  • Model就是我们我们抽离出来的obj
  • ViewModel就是我们创建的Vue对象实例

01-计数器的MVVM

它们之间如何工作呢?

  1. 首先ViewModel通过Data Binding让obj中的数据实时的在DOM中显示。
  2. 其次ViewModel通过DOM Listener来监听DOM事件,并且通过methods中的操作,来改变obj中的数据。

有了Vue帮助我们完成VueModel层的任务,在后续的开发,我们就可以专注于数据的处理,以及DOM的编写工作了。

6、创建Vue实例传入的options

在创建Vue实例的时候,传入了一个对象options。那么,这个options中可以包含哪些选项呢?详细解析

  • el:

    传入类型:string | HTMLElement

    作用:决定之后Vue实例会管理哪一个DOM,挂载要管理的元素

    限制:只在用 new 创建实例时生效

  • data:

    类型:Object | Function (组件当中data必须是一个函数)

    作用:Vue实例对应的数据对象

    限制:组件的定义只接受 function

  • methods:

    类型:{ [key: string]: Function }

    作用:定义属于Vue的一些方法,可以在其他地方调用,也可以在指令中使用。

  • components:

    类型:Object

    详细:包含 Vue 实例可用组件的哈希表。

  • computed:

    类型:{ [key: string]: Function | { get: Function, set: Function } }

    详细:

    计算属性将被混入到 Vue 实例中。所有 getter 和 setter 的 this 上下文自动地绑定为 Vue 实例。

    注意如果你为一个计算属性使用了箭头函数,则 this 不会指向这个组件的实例,不过你仍然可以将其实例作为函数的第一个参数来访问。

    计算属性的结果会被缓存,除非依赖的响应式 property 变化才会重新计算。注意,如果某个依赖 (比如非响应式 property) 在该实例范畴之外,则计算属性是不会被更新的。

  • 生命周期函数:

    所有的生命周期钩子hook自动绑定 this 上下文到实例中,因此你可以访问数据,对 property 和方法进行运算。(粗体表示常用)

    • beforeCreate:

      类型:Function

      详细:

      在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。

    • created

      类型:Function

      详细:

      在实例创建完成后被立即调用。在这一步,实例已完成以下的配置:数据观测 (data observer),property 和方法的运算,watch/event 事件回调。然而,挂载阶段还没开始,$el property 目前尚不可用。

    • beforeMount:

      类型:Function

      详细:

      在挂载开始之前被调用:相关的 render 函数首次被调用。

      该钩子在服务器端渲染期间不被调用。

    • mounted

      类型:Function

      详细:

      实例被挂载后调用,这时 el 被新创建的 vm.$el 替换了。如果根实例挂载到了一个文档内的元素上,当 mounted 被调用时 vm.$el 也在文档内。

      注意 mounted 不会保证所有的子组件也都一起被挂载。如果你希望等到整个视图都渲染完毕,可以在 mounted 内部使用 vm.$nextTick

    • beforeUpdate:

      类型:Function

      详细:

      数据更新时调用,发生在虚拟 DOM 打补丁之前。这里适合在更新之前访问现有的 DOM,比如手动移除已添加的事件监听器。

      该钩子在服务器端渲染期间不被调用,因为只有初次渲染会在服务端进行。

    • updated:

      类型:Function

      详细:

      由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。

      当这个钩子被调用时,组件 DOM 已经更新,所以你现在可以执行依赖于 DOM 的操作。然而在大多数情况下,你应该避免在此期间更改状态。如果要相应状态改变,通常最好使用计算属性watcher 取而代之。

      注意 updated 不会保证所有的子组件也都一起被重绘。如果你希望等到整个视图都重绘完毕,可以在 updated 里使用 vm.$nextTick

    • activated

      类型:Function

      详细:

      被 keep-alive 缓存的组件激活时调用。

      该钩子在服务器端渲染期间不被调用。

    • deactivated:

      类型:Function

      详细:

      被 keep-alive 缓存的组件停用时调用。

      该钩子在服务器端渲染期间不被调用。

    • beforeDestroy:

      类型:Function

      详细:

      实例销毁之前调用。在这一步,实例仍然完全可用。

      该钩子在服务器端渲染期间不被调用。

    • destroyed:

      类型:Function

      详细:

      实例销毁后调用。该钩子被调用后,对应 Vue 实例的所有指令都被解绑,所有的事件监听器被移除,所有的子实例也都被销毁。

      该钩子在服务器端渲染期间不被调用。

    • errorCaptured:

      2.5.0+ 新增(具体查看)

      类型:(err: Error, vm: Component, info: string) => ?boolean

      详细:

      当捕获一个来自子孙组件的错误时被调用。此钩子会收到三个参数:错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串。此钩子可以返回 false 以阻止该错误继续向上传播。

7、Vue的生命周期

以下图来自官网

Vue 实例生命周期

image-20210320025609038

简化:

image-20210320025717195

8、ES6补充

1、let/var

事实上var的设计可以看成JavaScript语言设计上的错误. 但是这种错误多半不能修复和移除, 以为需要向后兼容。于是,大概十年前,Brendan Eich就决定修复这个问题, 于是他添加了一个新的关键字: let。

我们可以将let看成更完美的var

1、块级作用域
  • JS中使用var来声明一个变量时, 变量的作用域主要是和函数的定义有关
  • 针对于其他块定义来说是没有作用域的,比如if/for等,这在我们开发中往往会引起一些问题。

我们可以通过ES6与ES5的不同来显示块级作用域的作用:

  • ES5中的var是没有块级作用域的(if/for),var只有在function中才有块级作用域。

    ES5之前因为if和for都没有块级作用域的概念,所以在很多时候,我们都必须借助于function的作用域来解决应用外面变量的问题。

  • ES6中的let是由块级作用的(if/for)

2、没有块级作用域引起的问题

for的块级:

1
2
3
4
5
6
var btns = document.getElementsByTagName('button');
for (var i=0; i<btns.length; i++) {
btns[i].addEventListener('click', function () {
console.log('第' + i + '个按钮被点击');
})
}

效果:无论点击哪个按钮,日志打印的都是第5个按钮被点击

image-20210320220031829

说明:由于var没有块级作用域,被var定义的i会随着i++的改变而改变。function里面的i受到for循环的i++的影响,被改变成了5,所以输出的都是第5个按钮被点击

3、解决方法:
  1. 用闭包可以解决问题。

    1
    2
    3
    4
    5
    6
    7
    8
    var btns = document.getElementsByTagName('button');
    for (var i=0; i<btns.length; i++) {
    (function (num) { // 0
    btns[i].addEventListener('click', function () {
    console.log('第' + num + '个按钮被点击');
    })
    })(i)
    }

    为什么闭包可以解决问题:函数是一个作用域。

  2. 用ES6的let

    1
    2
    3
    4
    5
    6
    const btns = document.getElementsByTagName('button')
    for (let i = 0; i < btns.length; i++) {
    btns[i].addEventListener('click', function () {
    console.log('第' + i + '个按钮被点击');
    })
    }

2、const

  • 在很多语言中已经存在, 比如C/C++中, 主要的作用是将某个变量修饰为常量。
  • 在JavaScript中也是如此, 使用const修饰的标识符为常量, 不可以再次赋值。

什么时候使用:

当我们修饰的标识符不会被再次赋值时, 就可以使用const来保证数据的安全性

建议:

在ES6开发中,优先使用const, 只有需要改变某一个标识符的时候才使用let。

使用const时要注意的点(以下代码为错误展示):

  • 一旦给const修饰的标识符被赋值之后, 不能修改

    1
    2
    const name = 'why';
    name = 'abc';
  • 在使用const定义标识符,必须进行赋值

    1
    const name;
  • 常量的含义是指向的对象不能修改, 但是可以改变对象内部的属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const obj = {
    name: 'why',
    age: 18,
    height: 1.88
    }

    // const修饰的标识符被赋值之后, 不能修改
    // obj = {}

    // 但是可以改变对象内部的属性
    obj.name = 'kobe';
    obj.age = 40;
    obj.height = 1.87;

3、对象增强写法

ES6中,对对象字面量进行了很多增强。

属性初始化简写和方法的简写:

  • 属性初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // ES5的写法
    const obj = {
    name: name,
    age: age,
    height: height
    }

    // ES6的写法
    const obj = {
    name,
    age,
    height,
    }
  • 方法的简写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // ES5的写法
    const obj = {
    run: function () {

    },
    eat: function () {

    }
    }
    // ES6的写法
    const obj = {
    run() {

    },
    eat() {

    }
    }

二、Vue基础语法

1、语法糖

指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

语法糖对程序员来说是友好的,但对机器本身却不怎么好。语法糖越甜,编译成的二进制也就越麻烦,出错的时候也会带来更多的麻烦。程序员要做的不是尽力避免错误,而是聚焦在快速发现并改正错误。真正以快速方式轻易解决错误,“快速的失败”远胜过“预防错误”。

Vue中常用的语法糖:

  • v-bind:
  • v-on@
  • v-once.once

2、插值语法

1、mustache语法

Mustache(胡子/胡须)是一款「logic-less(轻逻辑)」的前端模板引擎,它原本是基于 javascript 实现的,但是因为轻量易用,所以经过拓展目前支持更多的平台,如 java,.NET,PHP,C++ 等。Mustache 主要用于在表现和数据相分离的前端技术架构中,根据数据生成特定的动态内容,这些内容在网页中指的是HTML结构,而在小程序中则是WXML结构。在前后端分离的技术架构下面,前端模板引擎是一种可以被考虑的技术选型,随着重型框架(AngularJS、ReactJS、Vue)的流行,前端的模板技术已经成为了某种形式上的标配,Mustache 的价值在于其稳定和经典

主页:https://github.com/janl/mustache.js/

文档:https://mustache.github.io/mustache.5.html

项目主页:http://mustache.github.io/

Handlebars:基于 Mustache 的模板引擎:http://handlebarsjs.com/

对于Vue简单来说:"{{}}"(双大括号)不仅仅可以直接写变量,也可以写简单的表达式 更多的Mustache功能参考:https://www.jianshu.com/p/7f1cecdc27e1 我们可以像下面这样来使用,并且数据是响应式的: ![image-20210320030738172](VUE/11.png) #### 2、v-once 在某些情况下,我们可能不希望界面随意的跟随改变,这个时候,我们就可以使用一个Vue的指令:v-once v-once: - 该指令后面不需要跟任何表达式(比如v-for后面是由跟表达式的) - p该指令表示元素和组件(组件后面才会学习)只渲染一次,不会随着数据的改变而改变。 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}</h2>
<h2 v-once>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>
效果: ![image-20210320032116662](VUE/12.png) #### 3、v-html 某些情况下,我们从服务器请求到的数据本身就是一个HTML代码。如果我们直接通过"{{}}"来输出,会将HTML代码也一起输出。但是我们可能希望的是按照HTML格式进行解析,并且显示对应的内容。这个时候,我们就可以使用一个Vue的指令:v-html

v-html:

  • 该指令后面往往会跟上一个string类型
  • 会将string的html解析出来并且进行渲染

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="app">
<h2>{{url}}</h2>
<h2 v-html="url"></h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
url: '<a href="http://www.baidu.com">百度一下</a>'
}
})
</script>

效果:

image-202103200324301694、v-text(不常用)

nv-text作用和Mustache比较相似:都是用于将数据显示在界面中

nv-text

  • 通常情况下,接受一个string类型

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}, 李银河!</h2>
<h2 v-text="message">, 李银河!</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>

效果:

image-20210320032749380

5、v-pre

v-pre用于跳过这个元素和它子元素的编译过程,用于显示原本的Mustache语法。

比如下面的代码:

  • 第一个h2元素中的内容会被编译解析出来对应的内容
  • 第二个h2元素中会直接显示

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}</h2>
<h2 v-pre>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello World'
}
})
</script>

效果:

image-20210320032946332

6、v-cloak

在某些情况下,我们浏览器可能会直接显然出未编译的Mustache标签(加载过慢)。

v-cloak

  • 存在期限:在vue解析之前存在,在vue解析之后消失。
  • 该指令后面不需要跟任何表达式

cloak:斗篷(起遮挡作用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>

<div id="app" v-cloak>
<h2>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
// 在vue解析之前, div中有一个属性v-cloak
// 在vue解析之后, div中没有一个属性v-cloak
setTimeout(function () {
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
}, 1000)
</script>

</body>
</html>

效果:

  • 在没加v-cloak之前,浏览器先显示,过1s后显示“你好啊”
  • 在加了v-cloak之后,浏览器先显示空白,过1s后显示“你好啊”

3、绑定属性(v-bind)

1、v-bind基础

前面的插值指令主要作用是将值插入到我们模板的内容当中。但是,除了内容需要动态来决定外,某些属性我们也希望动态来绑定。

  • 比如动态绑定a元素中网站的链接href
  • 比如动态绑定img元素的src属性
  • 动态绑定一些类、样式

v-bind指令:

  • 作用:绑定一个或多个属性值,或者向另一个组件传递props值
  • 缩写::
  • 预期:any (with argument) | Object (without argument)
  • 参数:attrOrProp (optional)

通过Vue实例中的data绑定元素的src和href:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div id="app">
<!-- 错误的做法: 这里不可以使用mustache语法-->
<!--<img src="{{imgURL}}" alt="">-->
<!-- 正确的做法: 使用v-bind指令 -->
<img v-bind:src="imgURL" alt="">
<a v-bind:href="aHref">百度一下</a>
<!--<h2>{{}}</h2>-->

<!--语法糖的写法-->
<img :src="imgURL" alt="">
<a :href="aHref">百度一下</a>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
imgURL: 'https://vuejs.org/images/log.png',
aHref: 'https://vuejs.org'
}
})
</script>

2、v-bind绑定class

很多时候,我们希望动态的来切换class:

  • 当数据为某个状态时,字体显示红色。
  • 当数据另一个状态时,字体显示黑色。
1、绑定方式:对象语法

对象语法的含义是:class后面跟的是一个对象。

语法:v-bind:class=’{类名: boolean,类名: boolean}’

eg:v-bind:class=”{类名1: true, 类名2: boolean}

对象语法有下面这些用法:

  • 直接通过{}绑定一个类:

    1
    <h2 :class="{'active': isActive}">Hello World</h2>
  • 通过判断,传入多个值:

    1
    <h2 :class="{'active': isActive, 'line': isLine}">Hello World</h2>
  • 和普通的类同时存在,并不冲突

    注:如果isActive和isLine都为true,那么会有title/active/line三个class

    1
    <h2 class="title" :class="{'active': isActive, 'line': isLine}">Hello World</h2>
  • 如果过于复杂,可以放在一个methods或者computed

    注:classes是一个计算属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <h2 class="title" :class="classes">Hello World</h2>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    isActive: true,
    isLine: true
    },
    computed: {
    classes: function () {
    return {active: this.isActive, line: this.isLine}
    }
    }
    })
2、绑定方式:数组语法

数组语法的含义是:class后面跟的是一个数组。

数组语法有下面这些用法:

  • 直接通过{}绑定一个类:

    1
    <h2 :class="['active','line']">Hello World</h2>
  • 和普通的类同时存在,并不冲突

    注:会有title/active/line三个class

    1
    <h2 class="title" :class=“[‘active’, 'line']">Hello World</h2>
  • 如果过于复杂,可以放在一个methods或者computed中

    注:classes是一个计算属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <div id="app">
    <h2 class="title" :class="getClasses()">{{message}}</h2>
    </div>

    <script src="../js/vue.js"></script>
    <script>
    const app = new Vue({
    el: '#app',
    data: {
    active: 'aaaaaa',
    line: 'bbbbbbb'
    },
    methods: {
    getClasses: function () {
    return [this.active, this.line]
    }
    }
    })
    </script>

3、v-bind绑定style

我们可以利用v-bind:style来绑定一些CSS内联样式。

在写CSS属性名的时候,比如font-size

  • 我们可以使用驼峰式 (camelCase) fontSize
  • 或短横线分隔 (kebab-case,记得用单引号括起来) ‘font-size’
1、绑定方式:对象语法

style后面跟的是一个对象类型

  • 对象的key是CSS属性名称
  • 对象的value是具体赋的值,值可以来自于data中的属性
  • 如果过于复杂,可以放在一个methods或者computed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.title {
font-size: 50px;
color: red;
}
</style>
</head>
<body>

<div id="app">
<!--<h2 :style="{key(属性名): value(属性值)}">{{message}}</h2>-->

<!--'50px'必须加上单引号, 否则是当做一个变量去解析-->
<h2 :style="{fontSize: '50px'}">{{message}}</h2>

<!--finalSize当成一个变量使用-->
<!--<h2 :style="{fontSize: finalSize}">{{message}}</h2>-->
<h2 :style="{fontSize: finalSize + 'px', backgroundColor: finalColor}">{{message}}</h2>
<h2 :style="getStyles()">{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
finalSize: 100,
finalColor: 'red',
},
methods: {
getStyles: function () {
return {fontSize: this.finalSize + 'px', backgroundColor: this.finalColor}
}
}
})
</script>

</body>
</html>
2、绑定方式:数组语法

style后面跟的是一个数组类型

  • 多个值以,分割即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="app">
<h2 :style="[baseStyle, baseStyle1]">{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
baseStyle: {backgroundColor: 'red'},
baseStyle1: {fontSize: '100px'},
}
})
</script>

4、计算属性(computed)

1、是什么计算属性

在模板中可以直接通过插值语法显示一些data中的数据。但是在某些情况,我们可能需要对数据进行一些转化后再显示,或者需要将多个数据结合起来进行显示:

  • 比如我们有firstName和lastName两个变量,我们需要显示完整的名称:

    • undefined undefined
  • 但是如果多个地方都需要显示完整的名称,我们就需要写多个

    。代码臃肿

我们可以将上面的代码换成计算属性:写在实例Vue的computed选项中的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<div id="app">
<h2>{{firstName + ' ' + lastName}}</h2>
<h2>{{firstName}} {{lastName}}</h2>

<h2>{{getFullName()}}</h2>

<h2>{{fullName}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
firstName: 'Lebron',
lastName: 'James'
},
// computed: 计算属性()
computed: {
fullName: function () {
return this.firstName + ' ' + this.lastName
}
},
methods: {
getFullName() {
return this.firstName + ' ' + this.lastName
}
}
})
</script>

2、计算属性:

  • 解决代码臃肿

  • 可以进行一些更加复杂的操作

    image-20210320150614370

  • 计算属性会进行缓存,如果多次使用时,计算属性只会调用一次。

3、计算属性的setter和getter

每个计算属性都包含一个getter和一个setter

  • 在上面的例子中,我们只是使用getter来读取。

  • 在某些情况下,你也可以提供一个setter方法(不常用)。

    由于一般我们不希望有人能任意修改我们的计算属性的值,所以一般省略setter方法。而计算属性的getter就能简写成:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 简写前(如果有setter方法):
    computed: {
    fullName: {
    get() {
    console.log('---调用了fullName的get');
    return this.firstName + ' ' + this.lastName
    }
    },
    set(newValue) {
    console.log('---调用了fullName的get');
    const names = newValue.split(' ');
    this.firstName = names[0];
    this.lastName = names[1];
    }
    }

    // 简写后:
    computed: {
    fullName: function () {
    return this.firstName + ' ' + this.lastName
    }
    },

4、methods与computed

methods和computed看起来都可以实现我们的功能,那么为什么还要多一个计算属性这个东西呢?

原因:计算属性会进行缓存,如果多次使用时,计算属性只会调用一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<div id="app">
<!--1.直接拼接: 语法过于繁琐-->
<h2>{{firstName}} {{lastName}}</h2>

<!--2.通过定义methods-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->

<!--3.通过computed-->
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
// angular -> google
// TypeScript(microsoft) -> ts(类型检测)
// flow(facebook) ->
const app = new Vue({
el: '#app',
data: {
firstName: 'Kobe',
lastName: 'Bryant'
},
methods: {
getFullName: function () {
console.log('getFullName');
return this.firstName + ' ' + this.lastName
}
},
computed: {
fullName: function () {
console.log('fullName');
return this.firstName + ' ' + this.lastName
}
}
})

</script>

效果:

  • 当使用computed时:由于有缓存,浏览器只执行了一次。

    image-20210320153300912

  • 当使用methods时:没有缓存,浏览器执行多次,加重了浏览器的负担。

    image-20210320153616146

5、事件监听(v-on)

在前端开发中,我们需要经常和用于交互。

这个时候,我们就必须监听用户发生的时间,比如点击、拖拽、键盘事件等等

在Vue中如何监听事件呢?使用v-on指令

v-on:

  • 作用:绑定事件监听器
  • 缩写(语法糖):@
  • 预期:Function | Inline Statement | Object
  • 参数:event

1、v-on基础

  • 一般v-on后面加上:,然后加上动作如点击(click)、拖拽、键盘事件(keyup/keydown)等等。

  • 若v-on监听的事件简单,可以在v-on后面直接实现

    1
    <button v-on:click="counter++">+</button>
  • 若v-on监听的事件复杂,就需要将事件的实现抽取成一个方法

    1
    2
    3
    4
    5
    6
    7
    <button v-on:click="increment">+</button>

    methods: {
    increment() {
    this.counter++
    }
    }

2、v-on参数

当通过methods中定义方法,以供@click调用时,需要注意参数问题

  • 如果该方法不需要额外参数,那么方法后的()可以不添加。

    但是注意:如果方法本身中有一个参数,那么会默认将原生事件event参数传递进去。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!--1.事件调用的方法没有参数-->
    <!--1.1函数后添加()-->
    <button @click="btn1Click()">按钮1</button>
    <!--1.1函数后不添加()-->
    <button @click="btn1Click">按钮1</button>

    methods: {
    btn1Click() {
    console.log("btn1Click");
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <!--2.在事件定义时, 写方法时省略了小括号, 但是方法本身是需要一个参数的, 这个时候, Vue会默认将浏览器生产的event事件对象作为参数传入到方法-->
    <!--2.1函数后没添加()-->
    <button @click="btn2Click">按钮2</button>

    <!--2.2函数需要参数,()里传入参数-->
    <!--<button @click="btn2Click(123)">按钮2</button>-->

    <!--2.3如果函数需要参数,但是没有传入, 那么函数的形参为undefined-->
    <!--<button @click="btn2Click()">按钮2</button>-->

    methods: {
    btn2Click(event) {
    console.log('--------', event);
    }
    }

    2.1的效果:

    image-20210320232959026

    2.3的效果:

    image-20210320233429354

  • 如果需要同时传入某个参数,同时需要event时,可以通过$event传入事件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!--3.方法定义时, 我们需要event对象, 同时又需要其他参数-->
    <!--3.1在调用函数时, 如何手动的获取到浏览器参数的event对象: $event-->
    <button @click="btn3Click('abc', $event)">按钮3</button>
    <!--3.2在调用函数时,若event没有加$,那么浏览器将默认将event当成一个变量,若event在app实例里没有定义的话,浏览器会找不到该变量而报错并且返回undefined-->
    <button @click="btn3Click('abc', event)">按钮3</button>
    <!--3.3在调用函数时,若函数没传入参数,那么浏览器将默认将浏览器参数的event放入第一个参数中,又因为第二个参数没有传值,浏览器会将其变为undefined-->
    <button @click="btn3Click">按钮3</button>

    methods: {
    btn3Click(abc, event) {
    console.log('++++++++', abc, event);
    }
    }

    3.1的效果:

    image-20210320234009069

    3.2的效果:

    image-20210320235528249

    3.3的效果:

    image-20210320235715797

3、v-on修饰符

在某些情况下,我们拿到event的目的可能是进行一些事件处理。Vue提供了修饰符来帮助我们方便的处理一些事件:

  • .stop - 调用 event.stopPropagation()。
  • .prevent - 调用 event.preventDefault()。
  • .{keyCode | keyAlias} - 只当事件是从特定键触发时才触发回调。
  • .native - 监听组件根元素的原生事件。
  • .once - 只触发一次回调。

image-20210321000423484

更多修饰符参考官网的事件修饰符,以下来自官网。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- 阻止单击事件继续传播 -->
<a v-on:click.stop="doThis"></a>

<!-- 提交事件不再重载页面 -->
<form v-on:submit.prevent="onSubmit"></form>

<!-- 修饰符可以串联 -->
<a v-on:click.stop.prevent="doThat"></a>

<!-- 只有修饰符 -->
<form v-on:submit.prevent></form>

<!-- 添加事件监听器时使用事件捕获模式 -->
<!-- 即内部元素触发的事件先在此处理,然后才交由内部元素进行处理 -->
<div v-on:click.capture="doThis">...</div>

<!-- 只当在 event.target 是当前元素自身时触发处理函数 -->
<!-- 即事件不是从内部元素触发的 -->
<div v-on:click.self="doThat">...</div>

<!-- 点击事件将只会触发一次 -->
<a v-on:click.once="doThis"></a>

<!-- 滚动事件的默认行为 (即滚动行为) 将会立即触发 -->
<!-- 而不会等待 `onScroll` 完成 -->
<!-- 这其中包含 `event.preventDefault()` 的情况 -->
<div v-on:scroll.passive="onScroll">...</div>

<!-- 只有在 `key` 是 `Enter` 时调用 `vm.submit()` -->
<input v-on:keyup.enter="submit">

<input v-on:keyup.page-down="onPageDown">

6、条件判断

1、v-if、v-else-if、v-else

  • 这三个指令与JavaScript的条件语句if、else、else if类似。

  • Vue的条件指令可以根据表达式的值在DOM中渲染或销毁元素或组件。

简单的案例演示:

image-20210321004504280

image-20210321005848254

v-if的原理:

  • v-if后面的条件为false时,对应的元素以及其子元素不会渲染。

  • 也就是根本没有不会有对应的标签出现在DOM中。

2、一个简单的小案例(用户登陆方式切换)

用户再登录时,可以切换使用用户账号登录还是邮箱地址登录。类似如下情景:

image-20210321013857218

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>

<div id="app">
<span v-if="isUser">
<label for="username">用户账号</label>
<input type="text" id="username" placeholder="用户账号">
</span>
<span v-else>
<label for="email">用户邮箱</label>
<input type="text" id="email" placeholder="用户邮箱">
</span>
<button @click="isUser = !isUser">切换类型</button>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
isUser: true
}
})
</script>

</body>
</html>
1、问题

以上案例会有一个小问题:如果我们在有输入内容的情况下,切换了类型,我们会发现文字依然显示之前的输入的内容。

为什么呢?按道理讲,我们在一个input输入的内容(value),在切换到另外一个input元素后应该消失,因为在另一个input元素中,我们并没有输入内容。

2、问题解答
  • 这是因为Vue在进行渲染时,不会直接渲染在浏览器上面,Vue会在其之间构建一个虚拟NOM,Vue会先渲染在虚拟DOM上面,然后在渲染在浏览器上。而出于性能考虑,当出现两个只存在一个(if -else)的时候,会尽可能的复用已经存在的元素,而不是重新创建新的元素。
  • 在上面的案例中,Vue内部会发现原来(if)的input元素不再使用,直接作为else中的input来使用了。此时并不会重新构建一个input,并且改变的只有与之前input不同的内容(如for、id、placeholder等等),所以文本里面的内容不会改变。
3、解决方案

如果我们不希望Vue出现类似重复利用的问题,可以给对应的input添加key

并且需要我们保证key的值不同。(若key的值相同的话还是会继承文本内容)

1
2
3
4
5
6
7
8
<span v-if="isUser">
<label for="username">用户账号</label>
<input type="text" id="username" placeholder="用户账号" key="username">
</span>
<span v-else>
<label for="email">用户邮箱</label>
<input type="text" id="email" placeholder="用户邮箱" key="email">
</span>

3、v-show

v-show的用法和v-if非常相似,也用于决定一个元素是否渲染

v-if和v-show对比:

  • v-if: 当条件为false时, 包含v-if指令的元素, 根本就不会存在dom中
  • v-show: 当条件为false时, v-show只是给我们的元素添加一个行内样式: display: none

v-if和v-show都可以决定一个元素是否渲染,开发中如何选择呢:

  • 当需要在显示与隐藏之间切换很频繁时,使用v-show
  • 当只有一次切换时,通过使用v-if

7、循环遍历(v-for)

1、v-for遍历数组

当我们有一组数据需要进行渲染时,我们就可以使用v-for来完成

  • v-for的语法类似于JavaScript中的for循环。

  • 格式如下:item in items的形式。

  • 如果在遍历的过程中不需要使用索引值

    1
    2
    3
    <ul>
    <li v-for="item in names">{{item}}</li>
    </ul>
  • 如果在遍历的过程中,我们需要拿到元素在数组中的索引值

    1
    2
    3
    4
    5
    6
    <ul>
    <li v-for="(item, index) in names">
    // 使遍历从1开始
    {{index+1}}.{{item}}
    </li>
    </ul>

2、v-for遍历对象

当有一对象需要我们对其里面的数据进行渲染时,我们就可以使用v-for来完成

  • 在遍历对象的过程中, 如果只是获取一个值, 那么获取到的是value

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="item in info">{{item}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why
    • 18
    • 1.88
  • 获取key和value 格式: (value, key)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="(value, key) in info">{{value}}-{{key}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why-name
    • 18-age
    • 1.88-height
  • 获取key和value和index 格式: (value, key, index)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="(value, key, index) in info">{{value}}-{{key}}-{{index + 1}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why-name-1
    • 18-age-2
    • 1.88-height-3

3、v-for的组件的key属性

官方推荐我们在使用v-for时,给对应的元素或组件添加上一个:key属性。

为什么需要这个key属性呢?

  • 这个其实和Vue的虚拟DOM的Diff算法有关系

  • 我们借用React’s diff algorithm中的一张图来简单说明一下:

    image-20210321153710582

  • 当某一层有很多相同的节点时,也就是列表节点时,我们希望插入一个新的节点

    • 我们希望可以在B和C之间加一个F,Diff算法默认执行起来是这样的
    • 即把C更新成F,D更新成C,E更新成D,最后再插入E
  • 这样做会使程序的执行效率变低,所以可以用key这个属性来给每个节点做一个唯一标识

    • Diff算法就可以正确的识别此节点
    • 找到正确的位置区插入新的节点
  • key的作用主要是为了高效的更新虚拟DOM

4、检测数组更新(响应式)

因为Vue是响应式的,所以当数据发生变化时,Vue会自动检测数据变化,视图会发生对应的更新。

Vue中包含了一组观察数组编译的方法,使用它们改变数组也会触发视图的更新:

  • push():在数组末尾添加一个或多个元素

    1
    2
    3
    4
    5
    6
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.push('aaa')
    this.letters.push('aaaa', 'bbbb', 'cccc')
  • pop():删除数组中的最后一个元素

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.pop();
  • shift():删除数组中的第一个元素

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.shift();
  • unshift():在数组最前面添加一个或多个元素

    1
    2
    3
    4
    5
    6
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.unshift()
    this.letters.unshift('aaa', 'bbb', 'ccc')
  • splice(start,index,…items):删除元素/插入元素/替换元素

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    // 删除元素: 第二个参数传入你要删除几个元素(如果没有传,就删除后面所有的元素)
    this.letters.splice(1, 3)
    this.letters.splice(1)

    // 替换元素: 第二个参数, 表示我们要替换几个元素, 后面是用于替换前面的元素
    this.letters.splice(1, 3, 'm', 'n', 'l', 'x')

    // 插入元素: 第二个参数, 传入0, 并且后面跟上要插入的元素
    this.letters.splice(1, 0, 'x', 'y', 'z')
  • sort():对数组进行排序。(参数可以添加排序的规则的方法)

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    this.letters.sort()
  • reverse():

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    this.letters.reverse()

注意:通过索引值修改数组中的元素不能做到响应式

1
2
3
4
5
data: {
letters: ['a', 'd', 'c', 'b']
}

this.letters[0] = 'bbbbbb';

此时可以通过splice方法或Vue的set方法的方式来修改以达到响应式的目的

1
2
3
4
5
// splice方法
this.letters.splice(0, 1, 'bbbbbb')

// Vue的set(要修改的对象, 索引值, 修改后的值)
Vue.set(this.letters, 0, 'bbbbbb')

5、作业:(v-for + v-bind + v-on + 当前索引方法的应用)

需求:有一电影列表,点击哪一部影片,哪一部影片就表现为红色。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>homework</title>
</head>
<style>
.active{
color: red;
}
</style>
<body>

<div id = "app">
<ul>
<li v-for="(movie,index) in movies"
:class="{active: currentIndex === index}"
@click="isClick(index)">
{{index}}.{{movie}}
</li>
</ul>
</div>
<script src="../js/vue.js"></script>

<script>

const app = new Vue({
el: '#app',
data: {
movies: ['海贼王','火影忍者','进击的巨人','妖精的尾巴'],
currentIndex: -1
},
methods: {
isClick: function (index) {
this.currentIndex = index
}
}
})

</script>

</body>
</html>

6、高阶函数filter|map|reduce

1、filter:过滤作用

filter函数的参数是一个回调函数,返回值为一个数组:

  • 回调函数的参数为循环遍历的值n
  • 回调函数有一个要求: 必须返回一个boolean值
    • true: 当返回true时, 函数内部会自动将这次回调的n加入到新的数组中
    • false:当返回false时, 函数内部会过滤掉这次的n

使用:

1
2
3
4
5
6
const nums = [10, 20, 111, 222, 444, 40, 50]

// newNums = [10,20,40,50]
let newNums = nums.filter(function (n) {
return n < 100
})
2、map:映射作用

map函数的参数是一个回调函数,返回值为一个数组:

  • 回调函数的参数为循环遍历的值n
  • 可以在回调函数内对数组的值进行操作,map会帮操作完的值映射到一个新的数组

使用:

1
2
3
4
5
// newNums = [10,20,40,50]
// new2Nums = [20,40,80,100]
let new2Nums = newNums.map(function (n) { // 20
return n * 2
})
3、reduce:作用对数组中所有的内容进行汇总

map函数的参数是一个回调函数,和一个初始值

  • 回调函数有两个参数(previousValue,start)
    • previousValue:数组当前值的前一个值
    • start:数组当前值
  • 初始值为数组一开始值(第一个元素,index=0)的前一个值
1
2
3
4
5
// new2Nums = [20,40,80,100]
// total = 240
let total = new2Nums.reduce(function (preValue, n) {
return preValue + n
}, 0)
4、总结

需求:筛选出数组nums里所有小于100的值,然后就值乘以2再相加。

1
2
3
4
5
6
7
8
9
const nums = [10, 20, 111, 222, 444, 40, 50]

let total = nums.filter(function (n) {
return n < 100
}).map(function (n) {
return n * 2
}).reduce(function (prevValue, n) {
return prevValue + n
}, 0)

以上三个高阶函数的回调函数都可以用箭头函数表示。

1
let total = nums.filter(n => n < 100).map(n => n * 2).reduce((pre, n) => pre + n)

8、表单绑定(v-mode)

1、v-mode基础

表单控件在实际开发中是非常常见的。特别是对于用户信息的提交,需要大量的表单。

Vue中使用v-model指令来实现表单元素和数据的双向绑定。

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model="message">
{{message}}
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue'
}
})
</script>

image-20210321210459660

案例的解析:

当我们在输入框输入内容时,因为input中的v-model绑定了message,所以会实时将输入的内容传递给message,message发生改变。当message发生改变时,因为上面我们使用Mustache语法,将message的值插入到DOM中,所以DOM会发生响应的改变。所以,通过v-model实现了双向的绑定。

当然,我们也可以将v-model用于textarea元素。

2、v-mode的原理

v-model其实是一个语法糖,它的背后本质上是包含两个操作:

  • v-bind绑定一个value属性
  • v-on指令给当前元素绑定input事件
1
2
3
4
5
6
7
8
9
10
<input type="text" v-model="message">
<!--等同与-->
<input type="text" :value="message" @input="valueChange">
methods: {
valueChange(event) {
this.message = event.target.value;
}
}
<!--也等同与-->
<input type="text" v-bind:value="message" v-on:input="message = $event.target.value">

3、v-mode:radio

当存在多个单选框时,v-mode可用于将单选框的值和与之对应变量进行双向绑定。

其中一个label与一个input组合,label里面的for与input里面的id一一对应,实现用户点击文字就可以选中对应的单选框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="app">
<label for="male">
<input type="radio" id="male" value="男" v-model="sex">
</label>
<label for="female">
<input type="radio" id="female" value="女" v-model="sex">
</label>
<h2>您选择的性别是: {{sex}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
sex: '女'
}
})
</script>

4、v-mode:checkbox

checkbox复选框分为两种情况:单个勾选框和多个勾选框

  • 单个勾选框:

    • v-model即为布尔值

    • 此时input的value并不影响v-model的值

    • 常用于让用户点击同意协议后才能点击下一步的业务场景

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      <div id="app">
      <label for="licence">-->
      <input type="checkbox" id="licence" v-model="isAgree">同意协议
      </label>
      <h2>您选择的是: {{isAgree}}</h2>
      <button :disabled="!isAgree">下一步</button>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      isAgree: false,
      }
      })
      </script>
  • 多个复选框

    • 当是多个复选框时,因为可以选中多个,所以对应的data中属性是一个数组

    • 当选中某一个时,就会将input的value添加到数组中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      <div id="app">
      <input type="checkbox" value="篮球" v-model="hobbies">篮球
      <input type="checkbox" value="足球" v-model="hobbies">足球
      <input type="checkbox" value="乒乓球" v-model="hobbies">乒乓球
      <input type="checkbox" value="羽毛球" v-model="hobbies">羽毛球
      <h2>您的爱好是: {{hobbies}}</h2>

      <!--label中的:for与input的:id对应-->
      <label v-for="item in originHobbies" :for="item">
      <input type="checkbox" :value="item" :id="item" v-model="hobbies">{{item}}
      </label>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      hobbies: [],
      originHobbies: ['篮球', '足球', '乒乓球', '羽毛球', '台球', '高尔夫球']
      }
      })
      </script>

5、v-mode:select

select也分单选和多选两种情况:

  • 单选:只能选中一个值:

    • v-model绑定的是一个值

    • 当我们选中option中的一个时,会将它对应的value赋值到mySelect中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      <div id="app">
      <select name="abc" v-model="fruit">
      <option value="苹果">苹果</option>
      <option value="香蕉">香蕉</option>
      <option value="榴莲">榴莲</option>
      <option value="葡萄">葡萄</option>
      </select>
      <h2>您选择的水果是: {{fruit}}</h2>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      // 默认香蕉
      fruit: '香蕉'
      }
      })
      </script>
  • 多选:可以选中多个值(属性加上multiple):

    • v-model绑定的是一个数组

    • 当选中多个值时,就会将选中的option对应的value添加到数组mySelects中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      <div id="app">
      <select name="abc" v-model="fruits" multiple>
      <option value="苹果">苹果</option>
      <option value="香蕉">香蕉</option>
      <option value="榴莲">榴莲</option>
      <option value="葡萄">葡萄</option>
      </select>
      <h2>您选择的水果是: {{fruits}}</h2>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      fruits: []
      }
      })
      </script>

6、值绑定

动态的给value赋值而已。我们前面的value中的值,都是在定义input的时候直接给定的(写死),但是真实开发中,这些input的值可能是从网络获取或定义在data中的。所以我们可以通过v-bind:value动态的给value绑定值(其实就是v-bind在input中的应用)

7、修饰符

1、lazy修饰符

默认情况下,v-model默认是在input事件中同步输入框的数据的。也就是说,一旦有数据发生改变对应的data中的数据就会自动发生改变。

lazy修饰符可以让数据在失去焦点或者回车时才会更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model.lazy="message">
<h2>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue'
}
})
</script>

效果:

  • 聚焦时:

image-20210321214116893

  • 失焦时:

image-20210321214156460

2、number修饰符

默认情况下,在输入框中无论我们输入的是字母还是数字,都会被当做字符串类型进行处理。但是如果我们希望处理的是数字类型,那么最好直接将内容当做数字处理。

number修饰符可以让在输入框中输入的内容自动转成数字类型

1
2
3
4
5
6
7
8
9
10
11
12
13
<div id="app">
<input type="number" v-model.number="age">
<h2>{{age}}-{{typeof age}}</h2>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
age: 18
}
})
</script>

效果:

  • 没加number:

    image-20210321214845631

  • 加上number:

    image-20210321214942404

3、trim修饰符

如果输入的内容首尾有很多空格,通常我们希望将其去除,trim修饰符可以过滤内容左右两边的空格(浏览器会格式化显示时帮忙去掉多余的空格,但在代码里空格依旧存在,trim修饰符可以过滤内容左右两边的空格)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model.trim="name">
<h2>您输入的名字:{{name}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
name: ''
}
})
</script>

效果:

  • 没加trim:

    image-20210321215801147

  • 加上trim

    image-20210321215539094

三、组件化开发

1、什么是组件化

如果我们将一个页面中所有的处理逻辑全部放在一起,处理起来就会变得非常复杂,而且不利于后续的管理以及扩展。但如果,我们讲一个页面拆分成一个个小的功能块,每个功能块完成属于自己这部分独立的功能,那么之后整个页面的管理和维护就变得非常容易了。

如图:

  • 我们将一个完整的页面分成很多个组件。
  • 每个组件都用于实现页面的一个功能块。
  • 而每一个组件又可以进行细分。

image-20210321224044742

2、Vue组件化思想

组件化是Vue.js中的重要思想。它提供了一种抽象,让我们可以开发出一个个独立可复用的小组件来构造我们的应用。任何的应用都会被抽象成一颗组件树。

image-20210321224237074

组件化思想的应用:

  • 有了组件化的思想,我们在之后的开发中就要充分的利用它。
  • 尽可能的将页面拆分成一个个小的、可复用的组件。
  • 这样让我们的代码更加方便组织和管理,并且扩展性也更强。

注意:每一个组件都有独属于自己的data、methodscomputedcomponentstemplate等等。其中app可以看成所有组件的根组件(root)。但要注意,app也只能调用自己的儿子组件,不能去跨辈调用孙子组件。

3、注册组件

组件的使用分成三个步骤:

  1. 创建组件构造器
  2. 注册组件
  3. 使用组件:组件只能在注册过的实例里使用,否则Vue因没有进行管理不会加载组件。

注意:字符串的表达除了有''(单引号)、""(双引号)以外,还有``(尖引号)。尖引号可以实现字符串的跨行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<div id="app">
<!--3.使用组件-->
<my-cpn></my-cpn>
<my-cpn></my-cpn>
<my-cpn></my-cpn>
<my-cpn></my-cpn>

<div>
<div>
<my-cpn></my-cpn>
</div>
</div>
</div>

<!--以下没有在app实例里使用,Vue没有进行管理不会加载组件-->
<my-cpn></my-cpn>

<script src="../js/vue.js"></script>
<script>
// 1.创建组件构造器对象
const cpnC = Vue.extend({
template: `
<div>
<h2>组件标题</h2>
<p>我是组件中的一个段落内容</p>
</div>`
})

// 2.注册组件
// 参数1:组件的名称,参数2:组件构造器对象的名称
Vue.component('my-cpn', cpnC)

const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>

注册组件步骤解析

  1. Vue.extend():

    1. 调用Vue.extend()创建的是一个组件构造器;
    2. 通常在创建组件构造器时,传入template代表我们自定义组件的模板;
    3. 该模板就是在使用到组件的地方,要显示的HTML代码;
    4. 事实上,这种写法在Vue2.x的文档中几乎已经看不到了,它会直接使用下面我们会讲到的语法糖,但是在很多资料还是会提到这种方式,而且这种方式是学习后面方式的基础
  2. Vue.component():

    1. 调用Vue.component()是将刚才的组件构造器注册为一个组件,并且给它起一个组件的标签名称;
    2. 所以需要传递两个参数:
      1. 注册组件的标签名
      2. 组件构造器
  3. 组件必须挂载在某个Vue实例下,否则它不会生效:

    1. 我们来看下面我使用了三次

    2. 而第三次其实并没有生效:

      image-20210322023308247

4、全局组件和局部组件

当我们通过调用Vue.component()注册组件时,组件的注册是全局的

这意味着该组件可以在任意Vue示例下使用。

image-20210322023704676

如果我们注册的组件是挂载在某个实例中, 那么就是一个局部组件

image-20210322023725407

5、父组件和子组件

在前面我们看到了组件树:组件和组件之间存在层级关系。而其中一种非常重要的关系就是父子组件的关系

我们来看通过代码如何组成的这种层级关系:

image-20210322024427807

父子组件错误用法:以子标签的形式在Vue实例中使用

  • 因为当子组件注册到父组件的components时,Vue会编译好父组件的模块
  • 该模板的内容已经决定了父组件将要渲染的HTML(相当于父组件中已经有了子组件中的内容了)
  • 是只能在父组件中被识别的。
  • 类似这种用法,是会被浏览器忽略的。

6、注册组件语法糖

在上面注册组件的方式,可能会有些繁琐。Vue为了简化这个过程,提供了注册的语法糖。

主要是省去了调用Vue.extend()的步骤,而是可以直接使用一个对象来代替。

语法糖注册全局组件和局部组件:

  • 全局组件的语法糖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <div id="app">
    <cpn1></cpn1>
    </div>

    <script src="../js/vue.js"></script>

    <script>
    Vue.component('cpn1', {
    template: `
    <div>
    <h2>我是标题1</h2>
    <p>我是内容, 哈哈哈哈</p>
    </div>
    `
    })

    const app = new Vue({
    el: '#app'
    }
    })
    </script>
  • 局部组件的语法糖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <div id="app">
    <cpn2></cpn2>
    </div>

    <script src="../js/vue.js"></script>
    <script>
    // 注册局部组件的语法糖
    const app = new Vue({
    el: '#app',
    components: {
    'cpn2': {
    template: `
    <div>
    <h2>我是标题2</h2>
    <p>我是内容, 呵呵呵</p>
    </div>
    `
    }
    }
    })
    </script>

7、模板的分离写法

以上代码虽然通过语法糖简化了Vue组件的注册过程,但还有一个地方的写法比较麻烦,就是template模块写法。如果我们能将其中的HTML分离出来写,然后挂载到对应的组件上,必然结构会变得非常清晰。

Vue提供了两种方案来定义HTML模块内容:

  • 使用